Skip to content
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
27 changes: 27 additions & 0 deletions packages/functional-tests/tests/oauth/stepUpAuth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,33 @@ test.describe('severity-2', () => {
expect(after.acr).toBe('AAL2');
expect(typeof after.auth_time).toBe('number');
});

test('returns unmet_authentication_requirements when prompt=none forbids the challenge', async ({
target,
pages: { page, relier, signin },
testAccountTracker,
}) => {
const credentials = await testAccountTracker.signUp();

await relier.goto();
await relier.clickEmailFirst();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);
expect(await relier.isLoggedIn()).toBe(true);
expect((await relier.getAuthStatus()).acr).toBe('AAL1');

// 123Done merges query params over its route defaults. Runs outside local
// too: the per-client allowlist is only consulted when email or login_hint
// is present, and /api/step_up sends neither.
await page.goto(`${target.relierUrl}/api/step_up?prompt=none`);

// Wait on the error param, not the path — a successful redirect also
// lands on /api/oauth.
await page.waitForURL(/error=unmet_authentication_requirements/);
const params = new URL(page.url()).searchParams;
expect(params.get('error')).toBe('unmet_authentication_requirements');
expect(params.get('state')).toBeTruthy();
});
});
});

Expand Down
18 changes: 18 additions & 0 deletions packages/fxa-auth-server/docs/swagger/oauth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,26 @@ const OAUTH_AUTHORIZATION_POST = {
Authorize a new OAuth client connection to the user's account, returning a short-lived authentication code that the client can exchange for access tokens at the OAuth token endpoint.

This route behaves like the oauth-server /authorization endpoint except that it is authenticated directly with a sessionToken.

### Step-up authentication (RFC 9470)

An RP can request a higher authentication level with \`acr_values=AAL2\`, and can bound its freshness with \`max_age\`. The two are independent and each is optional; a session failing either one is rejected here with \`errno: 170\`.

The hosted sign-in UI resolves that with a second-factor challenge. Where it cannot — the RP sent \`prompt=none\` — it redirects to the registered \`redirect_uri\` with \`error=unmet_authentication_requirements\` instead. That redirect comes from the UI, never from this endpoint.
`,
],
plugins: {
'hapi-swagger': {
responses: {
400: {
description: dedent`
Failing requests may be caused by the following errors (this is not an exhaustive list):
- \`errno: 170\` - Requested \`acr_values\` or \`max_age\` could not be satisfied.
`,
},
},
},
},
};

const OAUTH_DESTROY_POST = {
Expand Down
30 changes: 30 additions & 0 deletions packages/fxa-auth-server/lib/oauth/grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,36 @@ describe('generateTokens', () => {
]);
});

// No token is minted for a rejected request, so RFC 9470 section 5 reduces to:
// acr tracks the achieved aal, never the requested acr_values.
it('reflects the achieved aal in the id_token acr claim', async () => {
requestedGrant.scope = ScopeSet.fromArray(['openid']);
requestedGrant.aal = 2;
const result = await generateTokens(requestedGrant);

const jwt = decodeJWT(result.id_token);
expect(jwt.claims.acr).toBe('AAL2');
expect(jwt.claims['fxa-aal']).toBe(2);
});

it('reports the lower level in acr when the session only reached AAL1', async () => {
requestedGrant.scope = ScopeSet.fromArray(['openid']);
requestedGrant.aal = 1;
const result = await generateTokens(requestedGrant);

const jwt = decodeJWT(result.id_token);
expect(jwt.claims.acr).toBe('AAL1');
});

it('omits acr when the grant carries no aal', async () => {
requestedGrant.scope = ScopeSet.fromArray(['openid']);
delete requestedGrant.aal;
const result = await generateTokens(requestedGrant);

const jwt = decodeJWT(result.id_token);
expect(jwt.claims.acr).toBeUndefined();
});

it('propagates auth_time (seconds) in id_token claims without re-dividing', async () => {
requestedGrant.scope = ScopeSet.fromArray(['openid']);
// authAt is already seconds since the epoch; auth_time must equal it, not
Expand Down
7 changes: 7 additions & 0 deletions packages/fxa-settings/src/lib/oauth/oauth-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ export const OAUTH_ERRORS: Record<string, AuthError> = {
message: 'Invalid id_token_hint',
response_error_code: 'invalid_request',
},
// 1100, not the next slot above: error-utils resolves AuthUiErrorNos first
// and it already occupies 1001-1067, so those render the wrong banner copy.
UNMET_AUTHENTICATION_REQUIREMENTS: {
errno: 1100,
message: 'Requested authentication level could not be satisfied',
response_error_code: 'unmet_authentication_requirements',
},
};

export class OAuthError extends Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,5 +551,51 @@ describe('models/integrations/oauth-relier', function () {
});
});

describe('getRedirectWithErrorUrl', () => {
function getIntegration(params: Record<string, unknown>) {
const integration = new OAuthWebIntegration(
new GenericData({ scope: 'profile', ...params }),
new GenericData({ scope: 'profile' }),
{
scopedKeysEnabled: true,
scopedKeysValidation: {},
isPromptNoneEnabled: true,
isPromptNoneEnabledClientIds: [],
}
);
// The registered URI the server handed back, not the RP-supplied param.
integration.clientInfo = { redirectUri: 'https://amo.test/oauth' } as any;
return integration;
}

it('returns the RFC 9470 error code and echoes state', () => {
const integration = getIntegration({ state: 'rp-state-123' });

const url = new URL(
integration.getRedirectWithErrorUrl(
new OAuthError('UNMET_AUTHENTICATION_REQUIREMENTS')
)
);

expect(url.origin + url.pathname).toBe('https://amo.test/oauth');
expect(url.searchParams.get('error')).toBe(
'unmet_authentication_requirements'
);
expect(url.searchParams.get('state')).toBe('rp-state-123');
});

it('falls back to the errno when the error carries no OAuth code', () => {
const integration = getIntegration({ state: 'rp-state-123' });

const url = new URL(
integration.getRedirectWithErrorUrl(OAUTH_ERRORS.TRY_AGAIN)
);

expect(url.searchParams.get('error')).toBe(
String(OAUTH_ERRORS.TRY_AGAIN.errno)
);
});
});

// TODO: OAuth Relier Model Test Coverage
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localiz
import { screen, waitFor } from '@testing-library/react';
import AuthorizationContainer from './container';
import { Config } from '../../lib/config';
import { OAUTH_ERRORS } from '../../lib/oauth/oauth-errors';
import { OAUTH_ERRORS, OAuthError } from '../../lib/oauth/oauth-errors';
import { Integration } from '../../models';
import AuthClient from 'fxa-auth-client/browser';
import VerificationMethods from '../../constants/verification-methods';
Expand Down Expand Up @@ -189,6 +189,39 @@ describe('AuthorizationContainer', () => {
});
});

it('redirects to the RP with unmet_authentication_requirements when step-up cannot be satisfied', async () => {
mockHandleNavigation.mockResolvedValue({
error: new OAuthError('UNMET_AUTHENTICATION_REQUIREMENTS'),
});

const getRedirectWithErrorUrl = jest
.fn()
.mockReturnValue(
'https://amo.test/oauth?error=unmet_authentication_requirements'
);
const mockIntegration = {
data: {},
wantsPromptNone: jest.fn().mockReturnValue(true),
returnOnError: jest.fn().mockReturnValue(true),
getRedirectWithErrorUrl,
validatePromptNoneRequest: jest.fn().mockResolvedValue(undefined),
getClientId: jest.fn().mockReturnValue('some-client-id'),
};

render(mockIntegration);

await waitFor(() => {
expect(ReactUtilsModule.hardNavigate).toHaveBeenCalledWith(
'https://amo.test/oauth?error=unmet_authentication_requirements'
);
});
expect(getRedirectWithErrorUrl).toHaveBeenCalledWith(
expect.objectContaining({
response_error_code: 'unmet_authentication_requirements',
})
);
});

it('navigates to /oauth if cached signed in returns PROMPT_NONE_NOT_SIGNED_IN error', async () => {
mockCachedSignIn.mockResolvedValue({
error: OAUTH_ERRORS.PROMPT_NONE_NOT_SIGNED_IN,
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-settings/src/pages/Authorization/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ const AuthorizationContainer = ({
finishOAuthFlowHandler,
queryParams: location.search,
authClient,
canRelayPromptNoneError:
isOAuthWebIntegration(integration) &&
integration.wantsPromptNone() &&
integration.returnOnError(),
};

const { error: navError } = await handleNavigation(navigationOptions);
Expand Down
6 changes: 6 additions & 0 deletions packages/fxa-settings/src/pages/Signin/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,12 @@ export interface NavigationOptions {
// session was established. Drives the /pair `choice_view` reason.
passwordCreationReason?: PasswordCreationReason;
accountHasTotp?: boolean;
// Set only by the Authorization container, and only when the RP accepts error
// redirects. It is the one caller that relays a returned error to the RP; the
// rest render errors in-FxA, and with return_on_error=false even this one
// does. Failing the request in those cases would dead-end the user instead of
// letting the interactive fallback complete the flow.
canRelayPromptNoneError?: boolean;
authClient: Pick<AuthClient, 'sessionResendVerifyCode'>;
}

Expand Down
148 changes: 148 additions & 0 deletions packages/fxa-settings/src/pages/Signin/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import * as ReactUtils from 'fxa-react/lib/utils';
import firefox from '../../lib/channels/firefox';
import config from '../../lib/config';
import { OAuthNativeServices } from '@fxa/accounts/oauth';
import { OAUTH_ERRORS, OAuthError } from '../../lib/oauth';
import { AuthUiErrors } from '../../lib/auth-errors/auth-errors';

jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
Expand Down Expand Up @@ -794,6 +796,152 @@ describe('Signin utils', () => {
);
});

describe('prompt=none', () => {
const buildPromptNoneOptions = (
overrides: Partial<NavigationOptions> = {}
) => {
const integration = createMockSigninOAuthIntegration();
integration.wantsTwoStepAuthentication = jest
.fn()
.mockReturnValue(true);
return createBaseNavigationOptions({
integration,
queryParams: '?client_id=abc',
canRelayPromptNoneError: true,
...overrides,
});
};

it('fails the request instead of challenging when the server reports errno 170', async () => {
const finishOAuthFlowHandler = jest.fn().mockResolvedValue({
error: AuthUiErrors.INSUFFICIENT_ACR_VALUES,
});
const navigationOptions = buildPromptNoneOptions({
// Has TOTP, so the enrolment divert is skipped and the grant
// attempt is what surfaces the unmet level.
accountHasTotp: true,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(finishOAuthFlowHandler).toHaveBeenCalledTimes(1);
expect(result.error).toBeInstanceOf(OAuthError);
expect(result.error).toEqual(
expect.objectContaining({
errno: OAUTH_ERRORS.UNMET_AUTHENTICATION_REQUIREMENTS.errno,
response_error_code: 'unmet_authentication_requirements',
})
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(hardNavigateSpy).not.toHaveBeenCalled();
});

it('does not reclassify an unverified session as an unmet level', async () => {
// Same errno branch, different meaning — interaction_required, not an
// unmet level. Pins that it is not mislabelled (FXA-14408).
const finishOAuthFlowHandler = jest.fn().mockResolvedValue({
error: AuthUiErrors.UNVERIFIED_SESSION,
});
const navigationOptions = buildPromptNoneOptions({
accountHasTotp: true,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(result.error).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith(
'/signin_token_code?client_id=abc',
expect.objectContaining({ replace: true })
);
});

it('still diverts to enrolment when the account has no TOTP', async () => {
// The server is authoritative: a passkey session is already
// fxa-aal>=2, so it would grant what a client-side check would refuse.
const finishOAuthFlowHandler = jest.fn();
const navigationOptions = buildPromptNoneOptions({
accountHasTotp: false,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(result.error).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith(
'/inline_totp_setup?client_id=abc',
expect.objectContaining({ replace: true })
);
});

it('does not fail the request when the RP opted out of error redirects', async () => {
// return_on_error=false means the container renders in-FxA rather than
// redirecting, so failing here would dead-end the user instead of
// letting enrolment complete the flow.
const finishOAuthFlowHandler = jest.fn().mockResolvedValue({
error: AuthUiErrors.INSUFFICIENT_ACR_VALUES,
});
const navigationOptions = buildPromptNoneOptions({
accountHasTotp: true,
canRelayPromptNoneError: false,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(result.error).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith(
'/inline_totp_setup?client_id=abc',
expect.objectContaining({ replace: true })
);
});

it('does not fail the request for callers that cannot relay it to the RP', async () => {
const finishOAuthFlowHandler = jest.fn().mockResolvedValue({
error: AuthUiErrors.INSUFFICIENT_ACR_VALUES,
});
const navigationOptions = buildPromptNoneOptions({
accountHasTotp: true,
canRelayPromptNoneError: false,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(result.error).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith(
'/inline_totp_setup?client_id=abc',
expect.objectContaining({ replace: true })
);
});

it('still challenges interactively when prompt=none was not requested', async () => {
const integration = createMockSigninOAuthIntegration();
integration.wantsTwoStepAuthentication = jest
.fn()
.mockReturnValue(true);
const finishOAuthFlowHandler = jest.fn().mockResolvedValue({
error: AuthUiErrors.INSUFFICIENT_ACR_VALUES,
});

const navigationOptions = createBaseNavigationOptions({
integration,
queryParams: '?client_id=abc',
accountHasTotp: true,
finishOAuthFlowHandler,
});

const result = await handleNavigation(navigationOptions);

expect(result.error).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith(
'/inline_totp_setup?client_id=abc',
expect.objectContaining({ replace: true })
);
});
});

it('returns a Settings-originated AAL upgrade to /settings', async () => {
const integration = createMockSigninOAuthIntegration();
const finishOAuthFlowHandler = jest.fn();
Expand Down
Loading