Repository navigation
feat(auth0-api-js): Principal + buildPrincipal #284
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
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b2f88cd
feat(auth0-api-js): add Principal + buildPrincipal (SDK-11359)
tusharpandey13 68adabf
refactor(auth0-api-js): apply Principal code-review polish (SDK-11359)
tusharpandey13 541209b
test(auth0-api-js): assert error message on all sub-rejection paths (…
tusharpandey13 e633c54
fix(auth0-api-js): harden buildPrincipal claim handling and document it
tusharpandey13 8331755
Merge remote-tracking branch 'origin/main' into push/p1-errors-principal
tusharpandey13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { buildPrincipal } from './principal.js'; | ||
| import { VerifyAccessTokenError } from './errors.js'; | ||
| import type { VerifiedAccessTokenClaims } from './types.js'; | ||
|
|
||
| function claims(overrides: Record<string, unknown>): VerifiedAccessTokenClaims { | ||
| return overrides as unknown as VerifiedAccessTokenClaims; | ||
| } | ||
|
|
||
| const BASE = { | ||
| sub: 'user|123', | ||
| exp: 9999999999, | ||
| }; | ||
|
|
||
| describe('buildPrincipal', () => { | ||
| // 1. sub missing | ||
| it('throws VerifyAccessTokenError when sub is missing', () => { | ||
| expect(() => buildPrincipal(claims({ exp: BASE.exp }))).toThrowError(VerifyAccessTokenError); | ||
| expect(() => buildPrincipal(claims({ exp: BASE.exp }))).toThrowError(/Missing or blank "sub"/); | ||
| }); | ||
|
|
||
| // 2. sub blank / whitespace-only | ||
| it('throws VerifyAccessTokenError when sub is blank string', () => { | ||
| expect(() => buildPrincipal(claims({ ...BASE, sub: ' ' }))).toThrowError(VerifyAccessTokenError); | ||
| expect(() => buildPrincipal(claims({ ...BASE, sub: ' ' }))).toThrowError(/Missing or blank "sub"/); | ||
| }); | ||
|
|
||
| it('throws VerifyAccessTokenError when sub is empty string', () => { | ||
| expect(() => buildPrincipal(claims({ ...BASE, sub: '' }))).toThrowError(VerifyAccessTokenError); | ||
| expect(() => buildPrincipal(claims({ ...BASE, sub: '' }))).toThrowError(/Missing or blank "sub"/); | ||
| }); | ||
|
|
||
| // 3. valid sub → set on Principal | ||
| it('sets sub from claims', () => { | ||
| const p = buildPrincipal(claims({ ...BASE })); | ||
| expect(p.sub).toBe('user|123'); | ||
| }); | ||
|
|
||
| // 4. client_id only → clientId = client_id | ||
| it('uses client_id for clientId when only client_id is present', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, client_id: 'cid_abc' })); | ||
| expect(p.clientId).toBe('cid_abc'); | ||
| }); | ||
|
|
||
| // 5. azp only (no client_id) → clientId = azp | ||
| it('falls back to azp for clientId when client_id is absent', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, azp: 'azp_xyz' })); | ||
| expect(p.clientId).toBe('azp_xyz'); | ||
| }); | ||
|
|
||
| // 6. both client_id and azp → client_id wins | ||
| it('prefers client_id over azp', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, client_id: 'cid_win', azp: 'azp_lose' })); | ||
| expect(p.clientId).toBe('cid_win'); | ||
| }); | ||
|
|
||
| // 7. neither client_id nor azp → null, no throw | ||
| it('sets clientId to null when neither client_id nor azp present', () => { | ||
| const p = buildPrincipal(claims({ ...BASE })); | ||
| expect(p.clientId).toBeNull(); | ||
| }); | ||
|
|
||
| // 8. permissions absent → null | ||
| it('sets permissions to null when permissions claim is absent', () => { | ||
| const p = buildPrincipal(claims({ ...BASE })); | ||
| expect(p.permissions).toBeNull(); | ||
| }); | ||
|
|
||
| // 9. permissions [] → [] (not null) | ||
| it('sets permissions to empty array when permissions claim is []', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, permissions: [] })); | ||
| expect(p.permissions).toEqual([]); | ||
| }); | ||
|
|
||
| // 10. permissions populated → array | ||
| it('sets permissions to the provided array', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, permissions: ['read:data', 'write:data'] })); | ||
| expect(p.permissions).toEqual(['read:data', 'write:data']); | ||
| }); | ||
|
|
||
| // 11. scope absent → [] | ||
| it('sets scopes to [] when scope claim is absent', () => { | ||
| const p = buildPrincipal(claims({ ...BASE })); | ||
| expect(p.scopes).toEqual([]); | ||
| }); | ||
|
|
||
| // 12. scope present → split array (multi-scope) | ||
| it('splits scope into scopes array', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, scope: 'openid profile email' })); | ||
| expect(p.scopes).toEqual(['openid', 'profile', 'email']); | ||
| }); | ||
|
|
||
| it('handles single scope value', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, scope: 'openid' })); | ||
| expect(p.scopes).toEqual(['openid']); | ||
| }); | ||
|
|
||
| it('filters empty strings from scope split', () => { | ||
|
tusharpandey13 marked this conversation as resolved.
|
||
| const p = buildPrincipal(claims({ ...BASE, scope: ' openid profile ' })); | ||
| expect(p.scopes).toEqual(['openid', 'profile']); | ||
| }); | ||
|
|
||
| // 13. org_id present → string; absent → null | ||
| it('sets orgId from org_id claim', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, org_id: 'org_abc' })); | ||
| expect(p.orgId).toBe('org_abc'); | ||
| }); | ||
|
|
||
| it('sets orgId to null when org_id is absent', () => { | ||
| const p = buildPrincipal(claims({ ...BASE })); | ||
| expect(p.orgId).toBeNull(); | ||
| }); | ||
|
|
||
| // 14. expiresAt = exp value | ||
| it('maps exp to expiresAt', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, exp: 1234567890 })); | ||
| expect(p.expiresAt).toBe(1234567890); | ||
| }); | ||
|
|
||
| it('splits scope on tabs and newlines', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, scope: 'read\twrite\nadmin' })); | ||
| expect(p.scopes).toEqual(['read', 'write', 'admin']); | ||
| }); | ||
|
|
||
| it('returns [] for an empty scope string', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, scope: '' })); | ||
| expect(p.scopes).toEqual([]); | ||
| }); | ||
|
|
||
| it('preserves duplicate scopes', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, scope: 'read read' })); | ||
| expect(p.scopes).toEqual(['read', 'read']); | ||
| }); | ||
|
|
||
| it('keeps an empty client_id (?? semantics, not ||)', () => { | ||
| const p = buildPrincipal(claims({ ...BASE, client_id: '', azp: 'a' })); | ||
| expect(p.clientId).toBe(''); | ||
| }); | ||
|
|
||
| it('throws VerifyAccessTokenError when sub is not a string', () => { | ||
| for (const sub of [123, {}, ['a'], true]) { | ||
| expect(() => buildPrincipal(claims({ ...BASE, sub }))).toThrowError(VerifyAccessTokenError); | ||
| } | ||
| }); | ||
|
|
||
| it('returns [] scopes when scope is not a string', () => { | ||
| expect(buildPrincipal(claims({ ...BASE, scope: ['read', 'write'] })).scopes).toEqual([]); | ||
| expect(buildPrincipal(claims({ ...BASE, scope: 42 })).scopes).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns [] permissions when permissions is present but not an array', () => { | ||
| expect(buildPrincipal(claims({ ...BASE, permissions: 'superadmin' })).permissions).toEqual([]); | ||
| expect(buildPrincipal(claims({ ...BASE, permissions: { admin: true } })).permissions).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns a copy of permissions, not the claim array', () => { | ||
| const perms = ['read:data']; | ||
| const p = buildPrincipal(claims({ ...BASE, permissions: perms })); | ||
| expect(p.permissions).toEqual(perms); | ||
| expect(p.permissions).not.toBe(perms); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import type { VerifiedAccessTokenClaims } from './types.js'; | ||
| import { VerifyAccessTokenError } from './errors.js'; | ||
|
|
||
| /** | ||
| * A resolved identity derived from a verified access token. | ||
| */ | ||
| export interface Principal { | ||
| /** The `sub` claim: the user or client the token was issued for. Always non-blank. */ | ||
| sub: string; | ||
| /** | ||
| * The OAuth client the token was issued to. Sourced from the `client_id` claim, | ||
| * falling back to `azp`. `null` when neither claim is present. | ||
| */ | ||
| clientId: string | null; | ||
| /** The `org_id` claim. `null` when the token was not issued in an organization context. */ | ||
| orgId: string | null; | ||
| /** | ||
| * Token expiry as an epoch timestamp in SECONDS (from the `exp` claim). | ||
| * Guaranteed present because callers pass claims from a verified token, and | ||
| * `verifyAccessToken` validates `exp`. | ||
| */ | ||
| expiresAt: number; | ||
| /** Scopes parsed from the space-delimited `scope` claim. `[]` when the claim is absent or malformed. */ | ||
| scopes: string[]; | ||
| /** | ||
| * RBAC permissions from the `permissions` claim. | ||
| * `null` means the claim is absent (for example, RBAC is not enabled for the API or | ||
| * "Add Permissions in the Access Token" is off), so permissions are unknown. | ||
| * `[]` means the claim is present but empty or malformed, so the principal has no permissions. | ||
| */ | ||
| permissions: string[] | null; | ||
|
tusharpandey13 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Builds a {@link Principal} from already-verified access token claims. | ||
| * | ||
| * This function does not verify the token. Pass only the claims returned by | ||
| * `verifyAccessToken()`. | ||
| * | ||
| * @param claims - Verified access token claims returned from `verifyAccessToken()` | ||
| * @returns The resolved principal. `permissions` is a copy, so mutating it does not affect `claims`. | ||
| * @throws {VerifyAccessTokenError} When the `sub` claim is missing, blank, or not a string. | ||
| * This error does not carry `WWW-Authenticate` headers (unlike errors thrown by | ||
| * `verifyAccessToken()`), so a caller that handles both in one block must add the challenge itself. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const claims = await apiClient.verifyAccessToken({ accessToken }); | ||
| * const principal = buildPrincipal(claims); | ||
| * if (principal.permissions?.includes('read:data')) { | ||
| * // authorized | ||
| * } | ||
| * ``` | ||
| */ | ||
| export function buildPrincipal(claims: VerifiedAccessTokenClaims): Principal { | ||
| const rawSub: unknown = claims.sub; | ||
| if (typeof rawSub !== 'string' || rawSub.trim() === '') { | ||
| throw new VerifyAccessTokenError('Missing or blank "sub" claim in access token.'); | ||
| } | ||
|
|
||
| const clientId: string | null = | ||
| (claims['client_id'] as string | undefined) ?? (claims['azp'] as string | undefined) ?? null; | ||
|
|
||
| const orgId: string | null = (claims['org_id'] as string | undefined) ?? null; | ||
|
|
||
| const rawScope: unknown = claims['scope']; | ||
| const scopes: string[] = | ||
| typeof rawScope === 'string' ? rawScope.split(/\s+/).filter(Boolean) : []; | ||
|
|
||
| const rawPermissions: unknown = claims['permissions']; | ||
| let permissions: string[] | null = null; | ||
| if (rawPermissions !== undefined) { | ||
| permissions = Array.isArray(rawPermissions) ? [...(rawPermissions as string[])] : []; | ||
| } | ||
|
|
||
| return { | ||
| sub: rawSub, | ||
| clientId, | ||
| orgId, | ||
| expiresAt: claims.exp as number, | ||
| scopes, | ||
| permissions, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.