Skip to content

Commit

Permalink
feat: introduce getClaims method to verify asymmetric JWTs (#1030)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?
* `getClaims` supports verifying JWTs (both asymmetric and symmetric)
and returns the entire set of claims in the JWT payload

---------

Co-authored-by: Stojan Dimitrovski <sdimitrovski@gmail.com>
  • Loading branch information
kangmingtay and hf authored Feb 10, 2025
1 parent 3d80039 commit daa2669
Show file tree
Hide file tree
Showing 11 changed files with 873 additions and 71 deletions.
45 changes: 38 additions & 7 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ services:
GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com
GOTRUE_MAILER_SUBJECTS_CONFIRMATION: 'Please confirm'
GOTRUE_EXTERNAL_PHONE_ENABLED: 'true'
GOTRUE_SMS_PROVIDER: "twilio"
GOTRUE_SMS_TWILIO_ACCOUNT_SID: "${GOTRUE_SMS_TWILIO_ACCOUNT_SID}"
GOTRUE_SMS_TWILIO_AUTH_TOKEN: "${GOTRUE_SMS_TWILIO_AUTH_TOKEN}"
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: "${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}"
GOTRUE_SMS_PROVIDER: 'twilio'
GOTRUE_SMS_TWILIO_ACCOUNT_SID: '${GOTRUE_SMS_TWILIO_ACCOUNT_SID}'
GOTRUE_SMS_TWILIO_AUTH_TOKEN: '${GOTRUE_SMS_TWILIO_AUTH_TOKEN}'
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: '${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}'
GOTRUE_SMS_AUTOCONFIRM: 'false'
GOTRUE_COOKIE_KEY: "sb"
GOTRUE_COOKIE_KEY: 'sb'
depends_on:
- db
restart: on-failure
Expand All @@ -47,6 +47,7 @@ services:
- '9998:9998'
environment:
GOTRUE_JWT_SECRET: '37c304f8-51aa-419a-a1af-06154e63707a'
GOTRUE_JWT_KEYS: '[{"kty":"oct","k":"Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo","kid":"12580317-221c-49b6-894a-f4473b8afe39","key_ops":["sign", "verify"],"alg":"HS256"},{"kty":"RSA","n":"y3KQnIXK6wkPQ5m0XWp7z54BNZzXJk4IxXy81zFophdBBqz6u5OCMqWkC6i3WB7rlax4xjmxxyGyYRODooqCQTGahmpXryAAKc3g-gDIAq2MqVwlpmvXDavCVRK4hK7DZ6wK4MHrliSNHCuCkwIH3ofxTxgUwpSkOT58iU1ZOua5E1Y6R_Ozt3gLHha0Xa7a4V23pkP7n0xBvJPzIqiS3MZ4CQ_pz-buXYRgCPQkUJvXFFcuxmyqoYzorwQ1YVBOmH2XMx26RrCIxgj7geo9eVQ9u5qCPpQCGV5biqYMC4_m1kurOGf62URGRzXtmVzrW1PZJAeGoqMz5Fcfr8hiwQ","e":"AQAB","d":"C4XxquvpEmbw9mM-VAwz9w58Aw1fIkxJMuZdy9KAmue2RyqFCRrRxQycvgxQVi1qKpAaRx_9ccn20IjKa-psdkTY-8QKM2EcoUGH_KEOsxghX3ZYq5RwGdYgq7DjwqAjcTvNYe2Z6mcnlvDf9HOo_nG0uUYj5uGEa7meVCiNZUiSVdNGs-vOTUD8yB5pbZ4ute8ebuUzCWGQ3YwSNoWLa-dbECSO7jeobCapdB52MjEwE3_Ii8BWoySeDP-DEFX_5RTM2Zeh81zXAgmOxpZYTkjMsrznyxxBbXn7CdT8WMEXrreGZwIt3Mu6XpsLF5mwmTQ_ZyoM6tJpn5LeAhnCAQ","p":"6xy1skrnlrGUWtZFSHixn_eRA_O3GXKNBE4wziWodGZaFYsmFijZHbuQT0WFqc0epvLHNdNPvubFrVfV-U7ZIarfSSq6qBwBzDrDQS060MvjJIjrI16pKlx2X727FR1ZuwxT27dNg-wRTgKcZqXEalkvFOTEYBlCtw2-vzI0aRs","q":"3YWwOAs4GRZ9eq_fqNujACWJFyUO9QgEDPDOMg0EZhY7WkAlehTxxVXg65spWnfx_0GSc72I5N5qdbY-yDh2Dl7zIxvwnqZaKMJn4PEFkeAfyg62XlJlkHIwOVSj6vLNUDdDmG7bO2k6MyQ59jeuAemIljf9WhALNy8c9R0K3VM","dp":"KJ4LHcQnAjeng5Hk4kJHnXUtjls6VKEfj5DaiaKj2YgdI_-oEsf3ylUu9yLxloYjN4BVvgzFiBtiJzI3exyOEmzsqj1Bhe1guiGkvcvMj2nJ0fP9e1zNKM5UfPHQMjOh3tigXCLst0-_JZT55BnbNuw1YAytiFSU2_755xoLR-U","dq":"dCP7V-bJ6p1X_FLpOGau9wy262OKi_0_4mj-Mk-Q1tUhGRg4jeEdQRDdc6lN7Rilz-ZZGkVs2FGkD0MVd3PisXYmk2m6pfMhoe0K-WxkNy8Ce7Vq99jLVwgHMIenyS6zZjMTRYAZgPSShu2fVe-rU2VVLyz7r5RpzOzuibRIVfE","qi":"i7ND2teiVLkbaAs6rHfo5DiD1nlsORNYnn8Y_FjF6utb5OUljZ6-5WyEDJN9oIUX8o_Il9E6js-z7nhvPfFZHQN7ZWuYI0rO5qmsCDS9jWJ4GR61SgzZuLT7Jpp_KtwjW70x5wZ1Y-GugOP1Wct1YZWHn5YyLhvO6X_vttSmcS0","kid":"638c54b8-28c2-4b12-9598-ba12ef610a29","key_ops":["verify"],"alg":"RS256"}]'
GOTRUE_JWT_EXP: 3600
GOTRUE_DB_DRIVER: postgres
DB_NAMESPACE: auth
Expand All @@ -66,7 +67,37 @@ services:
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com
GOTRUE_COOKIE_KEY: "sb"
GOTRUE_COOKIE_KEY: 'sb'
depends_on:
- db
restart: on-failure
autoconfirm_with_asymmetric_keys: # Signup enabled, autoconfirm on
image: supabase/auth:v2.169.0
ports:
- '9996:9996'
environment:
GOTRUE_JWT_SECRET: 'Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo'
GOTRUE_JWT_KEYS: '[{"kty":"oct","k":"Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo","kid":"12580317-221c-49b6-894a-f4473b8afe39","key_ops":["verify"],"alg":"HS256"},{"kty":"RSA","n":"y3KQnIXK6wkPQ5m0XWp7z54BNZzXJk4IxXy81zFophdBBqz6u5OCMqWkC6i3WB7rlax4xjmxxyGyYRODooqCQTGahmpXryAAKc3g-gDIAq2MqVwlpmvXDavCVRK4hK7DZ6wK4MHrliSNHCuCkwIH3ofxTxgUwpSkOT58iU1ZOua5E1Y6R_Ozt3gLHha0Xa7a4V23pkP7n0xBvJPzIqiS3MZ4CQ_pz-buXYRgCPQkUJvXFFcuxmyqoYzorwQ1YVBOmH2XMx26RrCIxgj7geo9eVQ9u5qCPpQCGV5biqYMC4_m1kurOGf62URGRzXtmVzrW1PZJAeGoqMz5Fcfr8hiwQ","e":"AQAB","d":"C4XxquvpEmbw9mM-VAwz9w58Aw1fIkxJMuZdy9KAmue2RyqFCRrRxQycvgxQVi1qKpAaRx_9ccn20IjKa-psdkTY-8QKM2EcoUGH_KEOsxghX3ZYq5RwGdYgq7DjwqAjcTvNYe2Z6mcnlvDf9HOo_nG0uUYj5uGEa7meVCiNZUiSVdNGs-vOTUD8yB5pbZ4ute8ebuUzCWGQ3YwSNoWLa-dbECSO7jeobCapdB52MjEwE3_Ii8BWoySeDP-DEFX_5RTM2Zeh81zXAgmOxpZYTkjMsrznyxxBbXn7CdT8WMEXrreGZwIt3Mu6XpsLF5mwmTQ_ZyoM6tJpn5LeAhnCAQ","p":"6xy1skrnlrGUWtZFSHixn_eRA_O3GXKNBE4wziWodGZaFYsmFijZHbuQT0WFqc0epvLHNdNPvubFrVfV-U7ZIarfSSq6qBwBzDrDQS060MvjJIjrI16pKlx2X727FR1ZuwxT27dNg-wRTgKcZqXEalkvFOTEYBlCtw2-vzI0aRs","q":"3YWwOAs4GRZ9eq_fqNujACWJFyUO9QgEDPDOMg0EZhY7WkAlehTxxVXg65spWnfx_0GSc72I5N5qdbY-yDh2Dl7zIxvwnqZaKMJn4PEFkeAfyg62XlJlkHIwOVSj6vLNUDdDmG7bO2k6MyQ59jeuAemIljf9WhALNy8c9R0K3VM","dp":"KJ4LHcQnAjeng5Hk4kJHnXUtjls6VKEfj5DaiaKj2YgdI_-oEsf3ylUu9yLxloYjN4BVvgzFiBtiJzI3exyOEmzsqj1Bhe1guiGkvcvMj2nJ0fP9e1zNKM5UfPHQMjOh3tigXCLst0-_JZT55BnbNuw1YAytiFSU2_755xoLR-U","dq":"dCP7V-bJ6p1X_FLpOGau9wy262OKi_0_4mj-Mk-Q1tUhGRg4jeEdQRDdc6lN7Rilz-ZZGkVs2FGkD0MVd3PisXYmk2m6pfMhoe0K-WxkNy8Ce7Vq99jLVwgHMIenyS6zZjMTRYAZgPSShu2fVe-rU2VVLyz7r5RpzOzuibRIVfE","qi":"i7ND2teiVLkbaAs6rHfo5DiD1nlsORNYnn8Y_FjF6utb5OUljZ6-5WyEDJN9oIUX8o_Il9E6js-z7nhvPfFZHQN7ZWuYI0rO5qmsCDS9jWJ4GR61SgzZuLT7Jpp_KtwjW70x5wZ1Y-GugOP1Wct1YZWHn5YyLhvO6X_vttSmcS0","kid":"638c54b8-28c2-4b12-9598-ba12ef610a29","key_ops":["sign","verify"],"alg":"RS256"}]'
GOTRUE_JWT_EXP: 3600
GOTRUE_DB_DRIVER: postgres
DB_NAMESPACE: auth
GOTRUE_API_HOST: 0.0.0.0
PORT: 9996
GOTRUE_DISABLE_SIGNUP: 'false'
API_EXTERNAL_URL: http://localhost:9996
GOTRUE_SITE_URL: http://localhost:9996
GOTRUE_MAILER_AUTOCONFIRM: 'true'
GOTRUE_SMS_AUTOCONFIRM: 'true'
GOTRUE_LOG_LEVEL: DEBUG
GOTRUE_OPERATOR_TOKEN: super-secret-operator-token
DATABASE_URL: 'postgres://postgres:postgres@db:5432/postgres?sslmode=disable'
GOTRUE_EXTERNAL_PHONE_ENABLED: 'true'
GOTRUE_SMTP_HOST: mail
GOTRUE_SMTP_PORT: 2500
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com
GOTRUE_COOKIE_KEY: 'sb'
depends_on:
- db
restart: on-failure
Expand Down Expand Up @@ -95,7 +126,7 @@ services:
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com
GOTRUE_COOKIE_KEY: "sb"
GOTRUE_COOKIE_KEY: 'sb'
depends_on:
- db
restart: on-failure
Expand Down
156 changes: 139 additions & 17 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isAuthRetryableFetchError,
isAuthSessionMissingError,
isAuthImplicitGrantRedirectError,
AuthInvalidJwtError,
} from './lib/errors'
import {
Fetch,
Expand All @@ -30,7 +31,6 @@ import {
_ssoResponse,
} from './lib/fetch'
import {
decodeJWTPayload,
Deferred,
getItemAsync,
isBrowser,
Expand All @@ -43,6 +43,9 @@ import {
supportsLocalStorage,
parseParametersFromURL,
getCodeChallengeAndMethod,
getAlgorithm,
validateExp,
decodeJWT,
} from './lib/helpers'
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
import { polyfillGlobalThis } from './lib/polyfills'
Expand Down Expand Up @@ -86,7 +89,6 @@ import type {
MFAVerifyParams,
AuthMFAVerifyResponse,
AuthMFAListFactorsResponse,
AMREntry,
AuthMFAGetAuthenticatorAssuranceLevelResponse,
AuthenticatorAssuranceLevels,
Factor,
Expand All @@ -100,7 +102,11 @@ import type {
MFAEnrollPhoneParams,
AuthMFAEnrollTOTPResponse,
AuthMFAEnrollPhoneResponse,
JWK,
JwtPayload,
JwtHeader,
} from './lib/types'
import { stringToUint8Array } from './lib/base64url'

polyfillGlobalThis() // Make "globalThis" available

Expand Down Expand Up @@ -140,7 +146,10 @@ export default class GoTrueClient {
protected storageKey: string

protected flowType: AuthFlowType

/**
* The JWKS used for verifying asymmetric JWTs
*/
protected jwks: { keys: JWK[] }
protected autoRefreshToken: boolean
protected persistSession: boolean
protected storage: SupportedStorage
Expand Down Expand Up @@ -220,7 +229,7 @@ export default class GoTrueClient {
} else {
this.lock = lockNoOp
}

this.jwks = { keys: [] }
this.mfa = {
verify: this._verify.bind(this),
enroll: this._enroll.bind(this),
Expand Down Expand Up @@ -1288,17 +1297,6 @@ export default class GoTrueClient {
}
}

/**
* Decodes a JWT (without performing any validation).
*/
private _decodeJWT(jwt: string): {
exp?: number
aal?: AuthenticatorAssuranceLevels | null
amr?: AMREntry[] | null
} {
return decodeJWTPayload(jwt)
}

/**
* Sets the session data from the current session. If the current session is expired, setSession will take care of refreshing it to obtain a new session.
* If the refresh token or access token in the current session is invalid, an error will be thrown.
Expand Down Expand Up @@ -1328,7 +1326,7 @@ export default class GoTrueClient {
let expiresAt = timeNow
let hasExpired = true
let session: Session | null = null
const payload = decodeJWTPayload(currentSession.access_token)
const { payload } = decodeJWT(currentSession.access_token)
if (payload.exp) {
expiresAt = payload.exp
hasExpired = expiresAt <= timeNow
Expand Down Expand Up @@ -2576,7 +2574,7 @@ export default class GoTrueClient {
}
}

const payload = this._decodeJWT(session.access_token)
const { payload } = decodeJWT(session.access_token)

let currentLevel: AuthenticatorAssuranceLevels | null = null

Expand All @@ -2599,4 +2597,128 @@ export default class GoTrueClient {
})
})
}

private async fetchJwk(kid: string, jwks: { keys: JWK[] } = { keys: [] }): Promise<JWK> {
// try fetching from the supplied jwks
let jwk = jwks.keys.find((key) => key.kid === kid)
if (jwk) {
return jwk
}

// try fetching from cache
jwk = this.jwks.keys.find((key) => key.kid === kid)
if (jwk) {
return jwk
}
// jwk isn't cached in memory so we need to fetch it from the well-known endpoint
const { data, error } = await _request(this.fetch, 'GET', `${this.url}/.well-known/jwks.json`, {
headers: this.headers,
})
if (error) {
throw error
}
if (!data.keys || data.keys.length === 0) {
throw new AuthInvalidJwtError('JWKS is empty')
}
this.jwks = data
// Find the signing key
jwk = data.keys.find((key: any) => key.kid === kid)
if (!jwk) {
throw new AuthInvalidJwtError('No matching signing key found in JWKS')
}
return jwk
}

/**
* @experimental This method may change in future versions.
* @description Gets the claims from a JWT. If the JWT is symmetric JWTs, it will call getUser() to verify against the server. If the JWT is asymmetric, it will be verified against the JWKS using the WebCrypto API.
*/
async getClaims(
jwt?: string,
jwks: { keys: JWK[] } = { keys: [] }
): Promise<
| {
data: { claims: JwtPayload; header: JwtHeader; signature: Uint8Array }
error: null
}
| { data: null; error: AuthError }
| { data: null; error: null }
> {
try {
let token = jwt
if (!token) {
const { data, error } = await this.getSession()
if (error || !data.session) {
return { data: null, error }
}
token = data.session.access_token
}

const {
header,
payload,
signature,
raw: { header: rawHeader, payload: rawPayload },
} = decodeJWT(token)

// Reject expired JWTs
validateExp(payload.exp)

// If symmetric algorithm or WebCrypto API is unavailable, fallback to getUser()
if (
!header.kid ||
header.alg === 'HS256' ||
!('crypto' in globalThis && 'subtle' in globalThis.crypto)
) {
const { error } = await this.getUser(token)
if (error) {
throw error
}
// getUser succeeds so the claims in the JWT can be trusted
return {
data: {
claims: payload,
header,
signature,
},
error: null,
}
}

const algorithm = getAlgorithm(header.alg)
const signingKey = await this.fetchJwk(header.kid, jwks)

// Convert JWK to CryptoKey
const publicKey = await crypto.subtle.importKey('jwk', signingKey, algorithm, true, [
'verify',
])

// Verify the signature
const isValid = await crypto.subtle.verify(
algorithm,
publicKey,
signature,
stringToUint8Array(`${rawHeader}.${rawPayload}`)
)

if (!isValid) {
throw new AuthInvalidJwtError('Invalid JWT signature')
}

// If verification succeeds, decode and return claims
return {
data: {
claims: payload,
header,
signature,
},
error: null,
}
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}
throw error
}
}
}
Loading

0 comments on commit daa2669

Please sign in to comment.