Skip to content
Closed
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
29 changes: 29 additions & 0 deletions packages/auth0-api-js/EXAMPLES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Examples

- [Build a Principal from verified claims](#build-a-principal-from-verified-claims)
- [Get a token on behalf of a user](#get-a-token-on-behalf-of-a-user)
- [Get an access token for a connection](#get-an-access-token-for-a-connection)
- [Multiple Custom Domains (MCD)](#multiple-custom-domains-mcd)
Expand All @@ -13,6 +14,34 @@
- [DPoP Behavior Matrix](#dpop-behavior-matrix)
- [Proof Timing Options](#proof-timing-options)

## Build a Principal from verified claims

```ts
import { ApiClient, buildPrincipal, VerifyAccessTokenError } from '@auth0/auth0-api-js';

const apiClient = new ApiClient({
domain: '<AUTH0_DOMAIN>',
audience: '<AUTH0_AUDIENCE>',
});

const claims = await apiClient.verifyAccessToken({ accessToken });

try {
const principal = buildPrincipal(claims);

// `permissions` is null when the claim is absent, [] when present but empty.
if (principal.permissions?.includes('read:data')) {
// authorized
}
console.log(principal.sub, principal.clientId, principal.orgId, principal.scopes);
} catch (e) {
if (e instanceof VerifyAccessTokenError) {
// No WWW-Authenticate headers on this error; add the challenge when responding 401.
}
throw e;
}
```

## Get a token on behalf of a user

Use `getTokenOnBehalfOf()` when your API receives an `Auth0` access token for itself and needs to
Expand Down
18 changes: 18 additions & 0 deletions packages/auth0-api-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,24 @@ In the current implementation, `getTokenOnBehalfOf()` forwards the incoming acce
The `OBO` result only includes access-token-oriented fields. It does not expose `id_token` or
`refresh_token`.

### Build a Principal from verified claims

`buildPrincipal()` turns the claims returned by `verifyAccessToken()` into a normalized `Principal`:

```ts
import { ApiClient, buildPrincipal } from '@auth0/auth0-api-js';

const claims = await apiClient.verifyAccessToken({ accessToken });
const principal = buildPrincipal(claims);
// { sub, clientId, orgId, expiresAt, scopes, permissions }
```

- `clientId` comes from `client_id`, falling back to `azp`; `null` when neither is present.
- `orgId` is `null` when the token has no `org_id`.
- `scopes` is parsed from the space-delimited `scope` claim; `[]` when absent or malformed.
- `permissions` is `null` when the claim is absent (permissions unknown) and `[]` when it is present but empty or malformed.
- A missing, blank, or non-string `sub` throws `VerifyAccessTokenError`. Unlike errors from `verifyAccessToken()`, it carries no `WWW-Authenticate` headers, so add the challenge yourself if you handle both in one `catch`.

## Feedback

### Contributing
Expand Down
5 changes: 5 additions & 0 deletions packages/auth0-api-js/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as pkg from './index.js';
import { ApiClient as DirectApiClient } from './api-client.js';
import { getCurrentActor as directGetCurrentActor, getDelegationChain as directGetDelegationChain } from './act.js';
import { getToken as directGetToken } from './token.js';
import { buildPrincipal as directBuildPrincipal } from './principal.js';
import {
MissingClientAuthError as BaseMissingClientAuthError,
TokenExchangeError as BaseTokenExchangeError,
Expand All @@ -30,6 +31,10 @@ describe('index exports', () => {
expect(pkg.getDelegationChain).toBe(directGetDelegationChain);
});

test('re-exports buildPrincipal', () => {
expect(pkg.buildPrincipal).toBe(directBuildPrincipal);
});

test('re-exports errors from this package', () => {
expect(pkg.InvalidRequestError).toBe(InvalidRequestError);
expect(pkg.MissingTransactionError).toBe(MissingTransactionError);
Expand Down
2 changes: 2 additions & 0 deletions packages/auth0-api-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export { ApiClient } from './api-client.js';
export * from './protected-resource-metadata.js';
export * from './errors.js';
export * from './types.js';
export { buildPrincipal } from './principal.js';
Comment thread
tusharpandey13 marked this conversation as resolved.
export type { Principal } from './principal.js';
export { getCurrentActor, getDelegationChain } from './act.js';
export { getToken } from './token.js';
export type { QueryLike, HeadersLike, BodyLike } from './token.js';
Expand Down
162 changes: 162 additions & 0 deletions packages/auth0-api-js/src/principal.spec.ts
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', () => {
Comment thread
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);
});
});
84 changes: 84 additions & 0 deletions packages/auth0-api-js/src/principal.ts
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;
Comment thread
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,
};
}
Loading