Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { LogtoAcr } from '@logto/schemas';

import {
buildInsufficientUserAuthenticationChallenge,
escapeQuotedString,
isAuthenticationContextFresh,
maximumAuthenticationAgeInSeconds,
satisfiesAuthenticationContextPolicy,
} from './authentication-context.js';

const now = 1_800_000_000_000;
const nowInSeconds = now / 1000;

const buildExtra = (overrides: Record<string, unknown> = {}) => ({
acr: LogtoAcr.FirstFactor,
auth_time: nowInSeconds,
...overrides,
});

describe('isAuthenticationContextFresh()', () => {
it.each([
['a just-completed authentication', 0],
['an authentication at the end of the window', maximumAuthenticationAgeInSeconds],
])('should accept %s', (_, ageInSeconds) => {
expect(isAuthenticationContextFresh(nowInSeconds - ageInSeconds, now)).toBe(true);
});

it('should reject an authentication one second past the window', () => {
expect(
isAuthenticationContextFresh(nowInSeconds - maximumAuthenticationAgeInSeconds - 1, now)
).toBe(false);
});

it('should reject a timestamp in the future', () => {
expect(isAuthenticationContextFresh(nowInSeconds + 1, now)).toBe(false);
});
});

describe('satisfiesAuthenticationContextPolicy()', () => {
it('should accept a fresh first-factor context', () => {
expect(satisfiesAuthenticationContextPolicy(buildExtra(), now)).toBe(true);
});

it('should accept a fresh MFA context, which satisfies the first-factor class', () => {
expect(satisfiesAuthenticationContextPolicy(buildExtra({ acr: LogtoAcr.Mfa }), now)).toBe(true);
});

it('should accept a context that carries unrelated claims, such as token exchange extras', () => {
expect(
satisfiesAuthenticationContextPolicy(
{ ...buildExtra(), amr: ['pwd'], organization_id: 'organization' },
now
)
).toBe(true);
});

it.each([
['a stale authentication', buildExtra({ auth_time: nowInSeconds - 601 })],
['a missing `acr`', { auth_time: nowInSeconds }],
['a missing `auth_time`', { acr: LogtoAcr.FirstFactor }],
['an `acr` that is not a string', buildExtra({ acr: [LogtoAcr.FirstFactor] })],
['an `acr` that is not a Logto ACR', buildExtra({ acr: 'urn:example:acr:1fa' })],
['a non-integer `auth_time`', buildExtra({ auth_time: nowInSeconds - 0.5 })],
['a string `auth_time`', buildExtra({ auth_time: `${nowInSeconds}` })],
['a future `auth_time`', buildExtra({ auth_time: nowInSeconds + 1 })],
['no extra at all', undefined],
['an empty extra', {}],
])('should reject %s', (_, extra) => {
expect(satisfiesAuthenticationContextPolicy(extra, now)).toBe(false);
});
});

describe('escapeQuotedString()', () => {
it('should escape backslashes and double quotes, and leave other characters alone', () => {
expect(escapeQuotedString('a\\b"c d')).toBe('a\\\\b\\"c d');
});
});

describe('buildInsufficientUserAuthenticationChallenge()', () => {
it('should build the RFC 9470 challenge that asks for the Account API policy', () => {
expect(buildInsufficientUserAuthenticationChallenge()).toBe(
'Bearer error="insufficient_user_authentication", ' +
'error_description="Recent user authentication is required", ' +
'acr_values="urn:logto:acr:1fa", max_age="600"'
);
});
});
75 changes: 75 additions & 0 deletions packages/core/src/middleware/koa-auth/authentication-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @file The Account API's authentication-context policy: the OIDC authentication context an access
* token must carry for a sensitive Account API operation to count as identity-verified, and the
* RFC 9470 challenge that asks a first-party client for one when the context is missing or stale.
*
* @see {@link https://www.rfc-editor.org/rfc/rfc9470.html | RFC 9470 OAuth 2.0 Step Up Authentication Challenge Protocol}
*/
import { LogtoAcr, acrSatisfies } from '@logto/schemas';
import { z } from 'zod';

/**
* The authentication context token claims, as `getExtraTokenClaimsForAuthenticationContext` writes
* them to an opaque access token's `extra` (and as the equivalent JWT access-token claims). Parsed
* strictly: a claim of the wrong type, a missing `acr`, or a fractional `auth_time` fails the
* policy instead of being coerced.
*/
const authenticationContextGuard = z.object({
acr: z.string(),
auth_time: z.number().int(),
});

/** The class a caller must reach to operate on its own account. `mfa` satisfies `1fa`. */
const requiredAcr = LogtoAcr.FirstFactor;

/** How old the end user's last authentication may be, in seconds. */
export const maximumAuthenticationAgeInSeconds = 600;

/**
* Whether the last user authentication is recent enough. `authTime` is in seconds, as OpenID
* Connect Core defines `auth_time`; token issuance time (`iat`) never substitutes for it, since a
* refreshed token carries the original authentication event.
*/
export const isAuthenticationContextFresh = (authTime: number, now = Date.now()) => {
const ageInSeconds = now / 1000 - authTime;

// A timestamp in the future is not a recent authentication either.
return ageInSeconds >= 0 && ageInSeconds <= maximumAuthenticationAgeInSeconds;
};

/**
* Whether the token's authentication context satisfies the Account API policy. The caller's
* application is checked separately: no context, however strong, lets a third-party application
* operate on the account here.
*/
export const satisfiesAuthenticationContextPolicy = (extra: unknown, now = Date.now()) => {
const result = authenticationContextGuard.safeParse(extra);

return (
result.success &&
acrSatisfies(result.data.acr, requiredAcr) &&
isAuthenticationContextFresh(result.data.auth_time, now)
);
};

/** Escape a value that is embedded in an HTTP `quoted-string`, per RFC 9110. */
export const escapeQuotedString = (value: string) =>
value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');

/**
* Build the RFC 9470 `WWW-Authenticate` challenge for a sensitive request that lacks recent user
* authentication, so a client can retry through a step-up instead of the legacy verification
* record. The JSON body of the error response is unchanged.
*/
export const buildInsufficientUserAuthenticationChallenge = () => {
const parameters: Array<[string, string]> = [
['error', 'insufficient_user_authentication'],
['error_description', 'Recent user authentication is required'],
['acr_values', requiredAcr],
['max_age', maximumAuthenticationAgeInSeconds.toString()],
];

return `Bearer ${parameters
.map(([key, value]) => `${key}="${escapeQuotedString(value)}"`)
.join(', ')}`;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { AdditionalIdentifier, LogtoAcr, VerificationType, type Application } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { Provider } from 'oidc-provider';
import Sinon from 'sinon';

import { mockApplication } from '#src/__mocks__/index.js';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { type Partial2 } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import { MockTenant } from '../../test-utils/tenant.js';

import { verificationRecordIdHeader } from './constants.js';
import type { WithAuthContext } from './index.js';

const { jest } = import.meta;

const provider = new Provider('https://logto.test');

const mockAccessToken = {
accountId: 'fooUser',
clientId: 'fooClient',
scopes: new Set(['openid']),
};

const nowInSeconds = () => Math.floor(Date.now() / 1000);

const koaOidcAuth = await pickDefault(import('./koa-oidc-auth.js'));

const baseCtx = createContextWithRouteParameters();

const ctx: WithAuthContext<Context & IRouterParamContext> = {
...baseCtx,
auth: {
type: 'user',
id: '',
scopes: new Set(),
},
};

const next = jest.fn();

/**
* A tenant whose application lookup resolves the access token's client, so the OIDC branch can
* tell a first-party application from a third-party one.
*/
const createTenantWithApplication = (
application: Partial<Application> | undefined,
verificationRecordsData?: Record<string, unknown>
) =>
new MockTenant(provider, {
applications: {
findApplicationById: jest.fn(async () => {
if (!application) {
throw new RequestError({ code: 'entity.not_exists_with_id', status: 404 });
}

return { ...mockApplication, ...application };
}),
},
...(verificationRecordsData && {
verificationRecords: {
findActiveVerificationRecordById: jest.fn(async (id: string) => ({
id,
userId: mockAccessToken.accountId,
data: verificationRecordsData,
expiresAt: Date.now() + 60_000,
})),
},
}),
} as unknown as Partial2<Queries>);

/** A token whose client is the given first-party or third-party application. */
const buildAccessToken = (extra: unknown, clientId = mockAccessToken.clientId) => ({
...mockAccessToken,
clientId,
extra,
});

const runMiddleware = async (
tenant: MockTenant,
accessToken: unknown,
verificationRecordId?: string
) => {
ctx.request = {
...baseCtx.request,
headers: {
authorization: 'Bearer access_token',
...(verificationRecordId && { [verificationRecordIdHeader]: verificationRecordId }),
},
};
Sinon.stub(provider.AccessToken, 'find').resolves(accessToken);
await koaOidcAuth(tenant)(ctx, next);

return ctx.auth;
};

describe('koaOidcAuth middleware with an OIDC authentication context', () => {
const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled;

beforeEach(() => {
ctx.auth = {
type: 'user',
id: '',
scopes: new Set(),
};
ctx.request = baseCtx.request;
Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', true);
});

afterEach(() => {
Sinon.restore();
});

afterAll(() => {
Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', originalIsDevFeaturesEnabled);
});

it.each([LogtoAcr.FirstFactor, LogtoAcr.Mfa])(
'should set identityVerified for a first-party token with a fresh %s context',
async (acr) => {
const auth = await runMiddleware(
createTenantWithApplication({ isThirdParty: false }),
buildAccessToken({ acr, auth_time: nowInSeconds() })
);

expect(auth).toMatchObject({ identityVerified: true, isFirstPartyClient: true });
}
);

it.each([
['a stale authentication', { acr: LogtoAcr.FirstFactor, auth_time: nowInSeconds() - 601 }],
['a future authentication', { acr: LogtoAcr.FirstFactor, auth_time: nowInSeconds() + 1 }],
['a missing `acr`', { auth_time: nowInSeconds() }],
['a malformed `acr`', { acr: 1, auth_time: nowInSeconds() }],
['a non-Logto `acr`', { acr: 'urn:example:acr:1fa', auth_time: nowInSeconds() }],
['a fractional `auth_time`', { acr: LogtoAcr.FirstFactor, auth_time: nowInSeconds() - 0.5 }],
['no authentication context', { some: 'claim' }],
['no extra at all', undefined],
])('should not set identityVerified with %s', async (_, extra) => {
const auth = await runMiddleware(
createTenantWithApplication({ isThirdParty: false }),
buildAccessToken(extra)
);

expect(auth).toMatchObject({ identityVerified: false });
});

it.each([
['a third-party application', { isThirdParty: true }],
['an application that cannot be resolved', undefined],
])('should not set identityVerified for %s, even with a fresh MFA context', async (_, app) => {
const auth = await runMiddleware(
createTenantWithApplication(app),
buildAccessToken({ acr: LogtoAcr.Mfa, auth_time: nowInSeconds() })
);

expect(auth).toMatchObject({ identityVerified: false, isFirstPartyClient: false });
});

it('should not set identityVerified for a CIMD client with a fresh MFA context', async () => {
const auth = await runMiddleware(
createTenantWithApplication({ isThirdParty: false }),
buildAccessToken(
{ acr: LogtoAcr.Mfa, auth_time: nowInSeconds() },
'https://client.example.com/metadata.json'
)
);

expect(auth).toMatchObject({ identityVerified: false, isFirstPartyClient: false });
});

it('should keep the legacy verification record path working', async () => {
const auth = await runMiddleware(
createTenantWithApplication(
{ isThirdParty: false },
{
type: VerificationType.Password,
identifier: {
type: AdditionalIdentifier.UserId,
value: mockAccessToken.accountId,
},
verified: true,
}
),
buildAccessToken({ some: 'claim' }),
'verification-record-id'
);

expect(auth).toMatchObject({ identityVerified: true });
});

it('should not set identityVerified when dev features are disabled', async () => {
Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', false);

const auth = await runMiddleware(
createTenantWithApplication({ isThirdParty: false }),
buildAccessToken({ acr: LogtoAcr.Mfa, auth_time: nowInSeconds() })
);

expect(auth).toMatchObject({ identityVerified: false });
expect(auth.isFirstPartyClient).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ describe('koaOidcAuth middleware', () => {
identityVerified: false,
clientId: mockAccessToken.clientId,
sessionUid: undefined,
// The tenant cannot resolve `fooClient`, and the third-party check fails closed.
isFirstPartyClient: false,
});
});

Expand All @@ -124,6 +126,8 @@ describe('koaOidcAuth middleware', () => {
identityVerified: false,
clientId: mockAccessToken.clientId,
sessionUid: 'fooSessionUid',
// The tenant cannot resolve `fooClient`, and the third-party check fails closed.
isFirstPartyClient: false,
});
});

Expand Down
Loading
Loading