Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,5 +854,6 @@ interface AuthFailureContext {
apikey: 'absent' | 'publishable' | 'secret' | 'legacy-jwt' | 'unrecognized'
}
configuredKeyNames?: Record<string, readonly string[]>
matchedKey?: { kind: 'publishable' | 'secret'; name: string; mode: string }
}
```
13 changes: 13 additions & 0 deletions docs/auth-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ withSupabase({ auth: 'publishable:*' }, handler)
withSupabase({ auth: 'secret:*' }, handler)
```

### Callers on Vercel

The Supabase Vercel integration sets `SUPABASE_SECRET_KEY` in Vercel to the most recently created secret key of the project. That value changes whenever a secret key is added or rotated, so it does not stay under any one name. Bare `secret` rejects it as soon as a key newer than `default` exists. For a function called from Vercel-hosted code, either accept any secret key or give Vercel a key of its own:

```ts
// Accept whichever secret key the integration synced
withSupabase({ auth: 'secret:*' }, handler)

// Or create a secret key named "vercel", set it in Vercel yourself as a
// variable the integration does not manage, and accept only that key
withSupabase({ auth: 'secret:vercel' }, handler)
```

### Which key matched?

When using named keys, `ctx.authMode` tells you the mode and `keyName` on the `AuthResult` (from core primitives) tells you which key matched. In the high-level `withSupabase` wrapper, the matched key is used internally for client creation.
Expand Down
2 changes: 2 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ The `hint` prioritises format mismatches, since sending the wrong _kind_ of key
- a legacy JWT-style `anon` / `service_role` key, where an `sb_publishable_…` / `sb_secret_…` key is expected
- a value that isn't a Supabase API key at all

When the key is a configured key of the same kind, held under a name the attempted mode does not accept, the `hint` names that key and the modes that would accept it: `secret:<name>` for that key alone, or `secret:*` for any key in the set. `details.matchedKeyName` carries the name. Bare `secret` and `publishable` accept only the key named `default`, so a named key sent to a bare mode lands here.

Otherwise the key was well-formed but simply unknown — usually a different Supabase project. `details.configuredKeyNames` lists the names configured for the attempted modes, and `details.received.apikey` gives the format of what you sent.

### `INVALID_JWT`
Expand Down
116 changes: 116 additions & 0 deletions src/core/verify-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,122 @@ describe('verifyCredentials', () => {
expect(result.data!.authMode).toBe('secret')
})

describe('key matched under another name', () => {
it('names the key bare secret rejected and the modes that would accept it', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_secret_vercel' },
{
auth: 'secret',
env: makeEnv({
secretKeys: {
default: 'sb_secret_default',
vercel: 'sb_secret_vercel',
},
}),
},
)
expect(error.code).toBe(InvalidApiKeyError)
expect(error.status).toBe(401)
expect(error.hint).toContain('secret key named "vercel"')
expect(error.hint).toContain('accepts only the key named "default"')
expect(error.hint).toContain("'secret:vercel'")
expect(error.hint).toContain("'secret:*'")
expect(error.details).toMatchObject({ matchedKeyName: 'vercel' })
})

it('names the key a named secret mode rejected', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_secret_mobile' },
{
auth: 'secret:web',
env: makeEnv({
secretKeys: { web: 'sb_secret_web', mobile: 'sb_secret_mobile' },
}),
},
)
expect(error.code).toBe(InvalidApiKeyError)
expect(error.hint).toContain('secret key named "mobile"')
expect(error.hint).toContain('accepts only the key named "web"')
expect(error.hint).toContain("'secret:mobile'")
expect(error.details).toMatchObject({ matchedKeyName: 'mobile' })
})

it('names the key bare publishable rejected', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_publishable_web' },
{
auth: 'publishable',
env: makeEnv({
publishableKeys: {
default: 'sb_publishable_default',
web: 'sb_publishable_web',
},
}),
},
)
expect(error.code).toBe(InvalidApiKeyError)
expect(error.hint).toContain('publishable key named "web"')
expect(error.hint).toContain("'publishable:web'")
expect(error.hint).toContain("'publishable:*'")
expect(error.details).toMatchObject({ matchedKeyName: 'web' })
})

it('still names the key when the key mode sits inside a chain', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_secret_vercel' },
{
auth: ['user', 'secret'],
env: makeEnv({
secretKeys: {
default: 'sb_secret_default',
vercel: 'sb_secret_vercel',
},
}),
},
)
expect(error.code).toBe(InvalidApiKeyError)
expect(error.hint).toContain('secret key named "vercel"')
expect(error.details).toMatchObject({ matchedKeyName: 'vercel' })
})

it('never puts key values in the hint or details', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_secret_vercel' },
{
auth: 'secret',
env: makeEnv({
secretKeys: {
default: 'sb_secret_default',
vercel: 'sb_secret_vercel',
},
}),
},
)
const serialized = JSON.stringify(error.toJSON())
expect(serialized).toContain('vercel')
expect(serialized).not.toContain('sb_secret_vercel')
expect(serialized).not.toContain('sb_secret_default')
})

it('keeps the generic hint when the key matches nothing', async () => {
const error = await failWith(
{ token: null, apikey: 'sb_secret_nope' },
{
auth: 'secret',
env: makeEnv({
secretKeys: {
default: 'sb_secret_default',
vercel: 'sb_secret_vercel',
},
}),
},
)
expect(error.code).toBe(InvalidApiKeyError)
expect(error.hint).toContain('matched no configured key')
expect(error.details).not.toHaveProperty('matchedKeyName')
})
})

it('stamps provenance on every error', async () => {
const error = await failWith(
{ token: null, apikey: null },
Expand Down
64 changes: 55 additions & 9 deletions src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,26 @@ type ModeSkip =
mode: string
keyKind: 'publishable' | 'secret'
}
/** A key was present and the mode had keys, but none matched. */
| { reason: 'apikey-mismatch' }
/**
* A key was present and the mode had keys, but none matched. When the key
* is a configured key of the same kind under another name, `matchedKeyName`
* carries that name so the error can point at the mode that would accept it.
*/
| {
reason: 'apikey-mismatch'
mode: string
keyKind: 'publishable' | 'secret'
matchedKeyName: string | null
}

/**
* An `apikey-mismatch` skip whose key is a configured key under another name.
*
* @internal
*/
type MisnamedKeySkip = Extract<ModeSkip, { reason: 'apikey-mismatch' }> & {
matchedKeyName: string
}

/**
* Result of attempting a single auth mode.
Expand All @@ -128,11 +146,6 @@ type ModeOutcome =

const NoToken: ModeOutcome = { kind: 'skip', skip: { reason: 'no-token' } }
const NoApiKey: ModeOutcome = { kind: 'skip', skip: { reason: 'no-apikey' } }
const ApiKeyMismatch: ModeOutcome = {
kind: 'skip',
skip: { reason: 'apikey-mismatch' },
}

/**
* Matches an `apikey` against a mode's key set, honouring the `:*` wildcard and
* named-key syntax. Returns the matched key name, or `null` when nothing matched.
Expand Down Expand Up @@ -207,7 +220,25 @@ async function tryMode(
}

const matched = await matchApiKey(credentials.apikey, keys, keyName)
if (matched === null) return ApiKeyMismatch
if (matched === null) {
// A wildcard has already compared against every key, so nothing else
// can match. Any other mode may have rejected a valid key of this kind
// held under a name it does not accept. That name is safe to report:
// the caller already holds the key.
const matchedKeyName =
keyName === '*'
? null
: await matchApiKey(credentials.apikey, keys, '*')
return {
kind: 'skip',
skip: {
reason: 'apikey-mismatch',
mode,
keyKind: base,
matchedKeyName,
},
}
}

return {
kind: 'match',
Expand Down Expand Up @@ -389,7 +420,22 @@ function explainFallthrough(
})
}
if (apikey !== 'absent') {
return Errors[InvalidApiKeyError](context)
const misnamed = skips.find(
(skip): skip is MisnamedKeySkip =>
skip.reason === 'apikey-mismatch' && skip.matchedKeyName !== null,
)
return Errors[InvalidApiKeyError](
misnamed
? {
...context,
matchedKey: {
kind: misnamed.keyKind,
name: misnamed.matchedKeyName,
mode: misnamed.mode,
},
}
: context,
)
}
return Errors[InvalidCredentialsError](context)
}
Expand Down
20 changes: 20 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,13 @@ export interface AuthFailureContext {
* Omitted for modes that don't use API keys.
*/
configuredKeyNames?: Record<string, readonly string[]>

/**
* Set when the rejected `apikey` is a configured key of `kind` held under
* `name`, and `mode` is the attempted mode that accepts a different name.
* Carries the name only, never the value.
*/
matchedKey?: { kind: 'publishable' | 'secret'; name: string; mode: string }
}

/**
Expand Down Expand Up @@ -664,6 +671,16 @@ const ApiKeyFormatLabel: Record<ApiKeyFormat, string> = {
*/
function apiKeyHint(context: AuthFailureContext): string {
const { authModes, received } = context

if (context.matchedKey) {
const { kind, name, mode } = context.matchedKey
const colonIndex = mode.indexOf(':')
const accepted = colonIndex === -1 ? 'default' : mode.slice(colonIndex + 1)
return (
`The key is the ${kind} key named "${name}", but auth: '${mode}' accepts only the key named "${accepted}". ` +
`Use auth: '${kind}:${name}' to accept that key, or auth: '${kind}:*' to accept any ${kind} key.`
)
}
const keyModes = authModes.filter((mode) =>
credentialForMode(mode)?.startsWith('apikey'),
)
Expand Down Expand Up @@ -781,6 +798,9 @@ const AuthErrorMap = {
...(context.configuredKeyNames
? { configuredKeyNames: context.configuredKeyNames }
: {}),
...(context.matchedKey
? { matchedKeyName: context.matchedKey.name }
: {}),
},
},
),
Expand Down
Loading