Skip to content

Commit daa2669

Browse files
kangmingtayhf
andauthored
feat: introduce getClaims method to verify asymmetric JWTs (#1030)
## 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 <[email protected]>
1 parent 3d80039 commit daa2669

File tree

11 files changed

+873
-71
lines changed

11 files changed

+873
-71
lines changed

infra/docker-compose.yml

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ services:
3232
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
3333
GOTRUE_MAILER_SUBJECTS_CONFIRMATION: 'Please confirm'
3434
GOTRUE_EXTERNAL_PHONE_ENABLED: 'true'
35-
GOTRUE_SMS_PROVIDER: "twilio"
36-
GOTRUE_SMS_TWILIO_ACCOUNT_SID: "${GOTRUE_SMS_TWILIO_ACCOUNT_SID}"
37-
GOTRUE_SMS_TWILIO_AUTH_TOKEN: "${GOTRUE_SMS_TWILIO_AUTH_TOKEN}"
38-
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: "${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}"
35+
GOTRUE_SMS_PROVIDER: 'twilio'
36+
GOTRUE_SMS_TWILIO_ACCOUNT_SID: '${GOTRUE_SMS_TWILIO_ACCOUNT_SID}'
37+
GOTRUE_SMS_TWILIO_AUTH_TOKEN: '${GOTRUE_SMS_TWILIO_AUTH_TOKEN}'
38+
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: '${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}'
3939
GOTRUE_SMS_AUTOCONFIRM: 'false'
40-
GOTRUE_COOKIE_KEY: "sb"
40+
GOTRUE_COOKIE_KEY: 'sb'
4141
depends_on:
4242
- db
4343
restart: on-failure
@@ -47,6 +47,7 @@ services:
4747
- '9998:9998'
4848
environment:
4949
GOTRUE_JWT_SECRET: '37c304f8-51aa-419a-a1af-06154e63707a'
50+
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"}]'
5051
GOTRUE_JWT_EXP: 3600
5152
GOTRUE_DB_DRIVER: postgres
5253
DB_NAMESPACE: auth
@@ -66,7 +67,37 @@ services:
6667
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
6768
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
6869
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
69-
GOTRUE_COOKIE_KEY: "sb"
70+
GOTRUE_COOKIE_KEY: 'sb'
71+
depends_on:
72+
- db
73+
restart: on-failure
74+
autoconfirm_with_asymmetric_keys: # Signup enabled, autoconfirm on
75+
image: supabase/auth:v2.169.0
76+
ports:
77+
- '9996:9996'
78+
environment:
79+
GOTRUE_JWT_SECRET: 'Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo'
80+
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"}]'
81+
GOTRUE_JWT_EXP: 3600
82+
GOTRUE_DB_DRIVER: postgres
83+
DB_NAMESPACE: auth
84+
GOTRUE_API_HOST: 0.0.0.0
85+
PORT: 9996
86+
GOTRUE_DISABLE_SIGNUP: 'false'
87+
API_EXTERNAL_URL: http://localhost:9996
88+
GOTRUE_SITE_URL: http://localhost:9996
89+
GOTRUE_MAILER_AUTOCONFIRM: 'true'
90+
GOTRUE_SMS_AUTOCONFIRM: 'true'
91+
GOTRUE_LOG_LEVEL: DEBUG
92+
GOTRUE_OPERATOR_TOKEN: super-secret-operator-token
93+
DATABASE_URL: 'postgres://postgres:postgres@db:5432/postgres?sslmode=disable'
94+
GOTRUE_EXTERNAL_PHONE_ENABLED: 'true'
95+
GOTRUE_SMTP_HOST: mail
96+
GOTRUE_SMTP_PORT: 2500
97+
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
98+
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
99+
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
100+
GOTRUE_COOKIE_KEY: 'sb'
70101
depends_on:
71102
- db
72103
restart: on-failure
@@ -95,7 +126,7 @@ services:
95126
GOTRUE_SMTP_USER: GOTRUE_SMTP_USER
96127
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
97128
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
98-
GOTRUE_COOKIE_KEY: "sb"
129+
GOTRUE_COOKIE_KEY: 'sb'
99130
depends_on:
100131
- db
101132
restart: on-failure

src/GoTrueClient.ts

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
isAuthRetryableFetchError,
2121
isAuthSessionMissingError,
2222
isAuthImplicitGrantRedirectError,
23+
AuthInvalidJwtError,
2324
} from './lib/errors'
2425
import {
2526
Fetch,
@@ -30,7 +31,6 @@ import {
3031
_ssoResponse,
3132
} from './lib/fetch'
3233
import {
33-
decodeJWTPayload,
3434
Deferred,
3535
getItemAsync,
3636
isBrowser,
@@ -43,6 +43,9 @@ import {
4343
supportsLocalStorage,
4444
parseParametersFromURL,
4545
getCodeChallengeAndMethod,
46+
getAlgorithm,
47+
validateExp,
48+
decodeJWT,
4649
} from './lib/helpers'
4750
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
4851
import { polyfillGlobalThis } from './lib/polyfills'
@@ -86,7 +89,6 @@ import type {
8689
MFAVerifyParams,
8790
AuthMFAVerifyResponse,
8891
AuthMFAListFactorsResponse,
89-
AMREntry,
9092
AuthMFAGetAuthenticatorAssuranceLevelResponse,
9193
AuthenticatorAssuranceLevels,
9294
Factor,
@@ -100,7 +102,11 @@ import type {
100102
MFAEnrollPhoneParams,
101103
AuthMFAEnrollTOTPResponse,
102104
AuthMFAEnrollPhoneResponse,
105+
JWK,
106+
JwtPayload,
107+
JwtHeader,
103108
} from './lib/types'
109+
import { stringToUint8Array } from './lib/base64url'
104110

105111
polyfillGlobalThis() // Make "globalThis" available
106112

@@ -140,7 +146,10 @@ export default class GoTrueClient {
140146
protected storageKey: string
141147

142148
protected flowType: AuthFlowType
143-
149+
/**
150+
* The JWKS used for verifying asymmetric JWTs
151+
*/
152+
protected jwks: { keys: JWK[] }
144153
protected autoRefreshToken: boolean
145154
protected persistSession: boolean
146155
protected storage: SupportedStorage
@@ -220,7 +229,7 @@ export default class GoTrueClient {
220229
} else {
221230
this.lock = lockNoOp
222231
}
223-
232+
this.jwks = { keys: [] }
224233
this.mfa = {
225234
verify: this._verify.bind(this),
226235
enroll: this._enroll.bind(this),
@@ -1288,17 +1297,6 @@ export default class GoTrueClient {
12881297
}
12891298
}
12901299

1291-
/**
1292-
* Decodes a JWT (without performing any validation).
1293-
*/
1294-
private _decodeJWT(jwt: string): {
1295-
exp?: number
1296-
aal?: AuthenticatorAssuranceLevels | null
1297-
amr?: AMREntry[] | null
1298-
} {
1299-
return decodeJWTPayload(jwt)
1300-
}
1301-
13021300
/**
13031301
* 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.
13041302
* If the refresh token or access token in the current session is invalid, an error will be thrown.
@@ -1328,7 +1326,7 @@ export default class GoTrueClient {
13281326
let expiresAt = timeNow
13291327
let hasExpired = true
13301328
let session: Session | null = null
1331-
const payload = decodeJWTPayload(currentSession.access_token)
1329+
const { payload } = decodeJWT(currentSession.access_token)
13321330
if (payload.exp) {
13331331
expiresAt = payload.exp
13341332
hasExpired = expiresAt <= timeNow
@@ -2576,7 +2574,7 @@ export default class GoTrueClient {
25762574
}
25772575
}
25782576

2579-
const payload = this._decodeJWT(session.access_token)
2577+
const { payload } = decodeJWT(session.access_token)
25802578

25812579
let currentLevel: AuthenticatorAssuranceLevels | null = null
25822580

@@ -2599,4 +2597,128 @@ export default class GoTrueClient {
25992597
})
26002598
})
26012599
}
2600+
2601+
private async fetchJwk(kid: string, jwks: { keys: JWK[] } = { keys: [] }): Promise<JWK> {
2602+
// try fetching from the supplied jwks
2603+
let jwk = jwks.keys.find((key) => key.kid === kid)
2604+
if (jwk) {
2605+
return jwk
2606+
}
2607+
2608+
// try fetching from cache
2609+
jwk = this.jwks.keys.find((key) => key.kid === kid)
2610+
if (jwk) {
2611+
return jwk
2612+
}
2613+
// jwk isn't cached in memory so we need to fetch it from the well-known endpoint
2614+
const { data, error } = await _request(this.fetch, 'GET', `${this.url}/.well-known/jwks.json`, {
2615+
headers: this.headers,
2616+
})
2617+
if (error) {
2618+
throw error
2619+
}
2620+
if (!data.keys || data.keys.length === 0) {
2621+
throw new AuthInvalidJwtError('JWKS is empty')
2622+
}
2623+
this.jwks = data
2624+
// Find the signing key
2625+
jwk = data.keys.find((key: any) => key.kid === kid)
2626+
if (!jwk) {
2627+
throw new AuthInvalidJwtError('No matching signing key found in JWKS')
2628+
}
2629+
return jwk
2630+
}
2631+
2632+
/**
2633+
* @experimental This method may change in future versions.
2634+
* @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.
2635+
*/
2636+
async getClaims(
2637+
jwt?: string,
2638+
jwks: { keys: JWK[] } = { keys: [] }
2639+
): Promise<
2640+
| {
2641+
data: { claims: JwtPayload; header: JwtHeader; signature: Uint8Array }
2642+
error: null
2643+
}
2644+
| { data: null; error: AuthError }
2645+
| { data: null; error: null }
2646+
> {
2647+
try {
2648+
let token = jwt
2649+
if (!token) {
2650+
const { data, error } = await this.getSession()
2651+
if (error || !data.session) {
2652+
return { data: null, error }
2653+
}
2654+
token = data.session.access_token
2655+
}
2656+
2657+
const {
2658+
header,
2659+
payload,
2660+
signature,
2661+
raw: { header: rawHeader, payload: rawPayload },
2662+
} = decodeJWT(token)
2663+
2664+
// Reject expired JWTs
2665+
validateExp(payload.exp)
2666+
2667+
// If symmetric algorithm or WebCrypto API is unavailable, fallback to getUser()
2668+
if (
2669+
!header.kid ||
2670+
header.alg === 'HS256' ||
2671+
!('crypto' in globalThis && 'subtle' in globalThis.crypto)
2672+
) {
2673+
const { error } = await this.getUser(token)
2674+
if (error) {
2675+
throw error
2676+
}
2677+
// getUser succeeds so the claims in the JWT can be trusted
2678+
return {
2679+
data: {
2680+
claims: payload,
2681+
header,
2682+
signature,
2683+
},
2684+
error: null,
2685+
}
2686+
}
2687+
2688+
const algorithm = getAlgorithm(header.alg)
2689+
const signingKey = await this.fetchJwk(header.kid, jwks)
2690+
2691+
// Convert JWK to CryptoKey
2692+
const publicKey = await crypto.subtle.importKey('jwk', signingKey, algorithm, true, [
2693+
'verify',
2694+
])
2695+
2696+
// Verify the signature
2697+
const isValid = await crypto.subtle.verify(
2698+
algorithm,
2699+
publicKey,
2700+
signature,
2701+
stringToUint8Array(`${rawHeader}.${rawPayload}`)
2702+
)
2703+
2704+
if (!isValid) {
2705+
throw new AuthInvalidJwtError('Invalid JWT signature')
2706+
}
2707+
2708+
// If verification succeeds, decode and return claims
2709+
return {
2710+
data: {
2711+
claims: payload,
2712+
header,
2713+
signature,
2714+
},
2715+
error: null,
2716+
}
2717+
} catch (error) {
2718+
if (isAuthError(error)) {
2719+
return { data: null, error }
2720+
}
2721+
throw error
2722+
}
2723+
}
26022724
}

0 commit comments

Comments
 (0)