Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce getClaims method to verify asymmetric JWTs #1030

Merged
merged 18 commits into from
Feb 10, 2025
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
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
}
hf marked this conversation as resolved.
Show resolved Hide resolved
// 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<
kangmingtay marked this conversation as resolved.
Show resolved Hide resolved
| {
data: { claims: JwtPayload; header: JwtHeader; signature: Uint8Array }
error: null
}
| { data: null; error: AuthError }
| { data: null; error: null }
> {
kangmingtay marked this conversation as resolved.
Show resolved Hide resolved
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)
kangmingtay marked this conversation as resolved.
Show resolved Hide resolved

// 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