Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/signing-service-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,25 @@ jobs:
deno-version: v1.x

- name: Start Supabase
run: supabase start
# Exclude services the signer tests don't need. Avoids the inbucket
# port-bind race (54324) that intermittently fails on GitHub runners
# when supabase start retries after a container startup hiccup.
run: supabase start --exclude inbucket,studio,storage-api,realtime,imgproxy,pgadmin-schema-diff,migra,pg-prove,pgbouncer,vector,supavisor

- name: Reset database
run: supabase db reset --force
run: supabase db reset --yes

- name: Apply pgsodium grants
- name: Apply pgsodium grants and seed key
# Connect as supabase_admin (the local-dev superuser; password is the
# same `postgres` value used for all built-in roles in the supabase
# CLI image). The `postgres` role in newer CLI images lacks
# pgsodium_keymaker membership and cannot write to pgsodium.key, so
# seeding under -U postgres fails with SQLSTATE 42501 — surfacing in
# seed.ts as P0002 "query returned no rows" from the encrypt trigger.
run: |
export PGPASSWORD=postgres
psql -h localhost -p 54322 -U postgres -d postgres -f supabase/setup/pgsodium_grants.sql
psql -h localhost -p 54322 -U postgres -d postgres -f supabase/setup/pgsodium_seed_key.sql
psql -h localhost -p 54322 -U supabase_admin -d postgres -f supabase/setup/pgsodium_grants.sql
psql -h localhost -p 54322 -U supabase_admin -d postgres -f supabase/setup/pgsodium_seed_key.sql

- name: Export Supabase env
run: |
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/supabase-migration-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Supabase Migration Lint

on:
push:
paths:
- 'supabase/migrations/**'
- 'scripts/lint-supabase-migrations.mjs'
- '.github/workflows/supabase-migration-lint.yml'
pull_request:
paths:
- 'supabase/migrations/**'
- 'scripts/lint-supabase-migrations.mjs'
- '.github/workflows/supabase-migration-lint.yml'

jobs:
lint:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Lint Supabase migrations
# `--ignore-before` suppresses pre-cutoff legacy violations in the exit
# code (they are still printed for visibility). The cutoff marks the
# signer-hardening PR; every migration dated on or after it is enforced.
run: node scripts/lint-supabase-migrations.mjs --summary --ignore-before=20260423
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,6 @@ dist/

# Benchmark results
scripts/benchmark-results/

# Claude Code local state (per-machine permissions, runtime locks)
.claude/
2 changes: 1 addition & 1 deletion app/api/auth/siwe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
console.log('SIWE API', searchParams, request.method, request.headers);
console.log('SIWE API', searchParams, request.method);

return NextResponse.redirect('/');
} catch (error) {
Expand Down
85 changes: 79 additions & 6 deletions app/api/oauth/decision/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,45 @@ function buildLoginRedirect(authorizationId: string): string {
return `/login?redirect=${encodeURIComponent(redirectPath)}`;
}

function getOAuthHelpers(supabase: ReturnType<typeof createServerClient>) {
type OAuthHelpers = {
approve?: (id: string, opts?: { scopes?: string[] }) => Promise<any>;
deny?: (id: string) => Promise<any>;
getDetails?: (id: string) => Promise<any>;
};

function getOAuthHelpers(supabase: ReturnType<typeof createServerClient>): OAuthHelpers {
const oauth = (supabase.auth as unknown as { oauth?: Record<string, unknown> }).oauth;
if (!oauth) return {};
return {
approve: oauth && (oauth as { approveAuthorization?: (id: string) => Promise<any> }).approveAuthorization,
deny: oauth && (oauth as { denyAuthorization?: (id: string) => Promise<any> }).denyAuthorization,
approve: (oauth as { approveAuthorization?: OAuthHelpers['approve'] }).approveAuthorization,
deny: (oauth as { denyAuthorization?: OAuthHelpers['deny'] }).denyAuthorization,
getDetails: (oauth as { getAuthorizationDetails?: OAuthHelpers['getDetails'] }).getAuthorizationDetails,
};
}

/**
* Parse approved scopes from the form submission. Supports both
* `scopes` (JSON array string) and multi-valued `scope` form fields.
* Returns `null` if the form did not specify any — meaning caller should
* accept whatever scopes the stored authorization request carries.
*/
function parseApprovedScopes(formData: FormData): string[] | null {
const scopesField = formData.get('scopes');
if (typeof scopesField === 'string' && scopesField.length > 0) {
try {
const parsed = JSON.parse(scopesField);
if (Array.isArray(parsed)) {
return parsed.filter((s): s is string => typeof s === 'string');
}
} catch {
// fall through to space-separated parse
}
return scopesField.trim().split(/\s+/).filter(Boolean);
}
const multi = formData.getAll('scope').filter((v): v is string => typeof v === 'string' && v.length > 0);
return multi.length > 0 ? multi : null;
}

export async function POST(request: Request) {
const formData = await request.formData();
const decision = formData.get('decision');
Expand Down Expand Up @@ -52,16 +83,58 @@ export async function POST(request: Request) {
return NextResponse.redirect(buildLoginRedirect(authorizationId));
}

const { approve, deny } = getOAuthHelpers(supabase);
const { approve, deny, getDetails } = getOAuthHelpers(supabase);
if (!approve || !deny) {
return NextResponse.json(
{ error: 'Supabase OAuth helpers are unavailable. Upgrade @supabase/supabase-js.' },
{ status: 500 }
);
}

const action = decision === 'approve' ? approve : deny;
const { data, error } = await action(authorizationId);
if (decision === 'deny') {
const { data, error } = await deny(authorizationId);
if (error) {
return NextResponse.json({ error: error.message || 'Failed to update authorization' }, { status: 400 });
}
const redirectTo = data?.redirect_to || data?.redirect_url;
if (!redirectTo) {
return NextResponse.json({ error: 'Missing redirect URL' }, { status: 500 });
}
return NextResponse.redirect(redirectTo, { status: 303 });
}

// APPROVE path — validate that any user-submitted scope set is a subset
// of the stored authorization request's scopes. A malicious client cannot
// widen permissions beyond what they initially requested, and a tampered
// consent form cannot grant scopes the user never saw described.
const approvedScopes = parseApprovedScopes(formData);
let effectiveScopes: string[] | undefined;

if (approvedScopes && getDetails) {
const { data: details, error: detailsError } = await getDetails(authorizationId);
if (detailsError) {
return NextResponse.json({ error: detailsError.message || 'Failed to load authorization' }, { status: 400 });
}
const allowed = Array.isArray(details?.scopes) ? (details.scopes as string[]) : [];
const outOfBand = approvedScopes.filter((s) => !allowed.includes(s));
if (outOfBand.length > 0) {
return NextResponse.json(
{ error: 'requested scopes exceed the authorization set', invalid: outOfBand },
{ status: 400 }
);
}
effectiveScopes = approvedScopes;
}

// TODO: Full scope-threading in the OAuth token payload depends on Supabase's
// approveAuthorization signature. Current @supabase/supabase-js typings do not
// document a scopes option, so we pass it opportunistically — the helper will
// use it when supported and ignore it otherwise. If the installed version
// doesn't honor it, the token receives the scopes from the stored
// authorization request, which already went through user consent.
const { data, error } = effectiveScopes
? await approve(authorizationId, { scopes: effectiveScopes })
: await approve(authorizationId);

if (error) {
return NextResponse.json({ error: error.message || 'Failed to update authorization' }, { status: 400 });
Expand Down
Loading
Loading