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
45 changes: 45 additions & 0 deletions packages/123done/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ function setupOAuthFlow(req, action, options = {}, cb) {
req.session.state = params.state;
oauthFlows[params.state] = { params: params, config: config };

// Clear stale profile-AAL2 enforcement so an abandoned flow can't leak.
req.session.profileAal2Required = null;
req.session.profileAal2Bounced = null;

return cb(
null,
{
Expand Down Expand Up @@ -162,6 +166,29 @@ module.exports = function (app, db) {
);
});

// AMO-style enforcement: session AAL2 plus a post-grant
// profile.twoFactorAuthentication check. Passkey-only accounts satisfy
// session AAL2 but not profile AAL2, so the RP bounces back unless the
// FxA-side divert to /inline_totp_setup runs.
app.get('/api/profile_aal2', function (req, res) {
const wasBounced = !!req.session.profileAal2Bounced;
const opts = { acrValues: 'AAL2' };
if (wasBounced) {
// Skip the cached-session short-circuit so the divert can re-run.
opts.prompt = 'login';
opts.max_age = 0;
}
setupOAuthFlow(req, 'email', opts, function (err, params, oauthConfig) {
if (err) {
return res.send(400, err);
}
req.session.profileAal2Required = true;
Comment thread
vpomerleau marked this conversation as resolved.
// Restore the bounce flag that setupOAuthFlow cleared, to cap at one pass.
req.session.profileAal2Bounced = wasBounced;
return res.redirect(redirectUrl(params, oauthConfig));
});
});

// begin a force auth flow
app.get('/api/force_auth', function (req, res) {
setupOAuthFlow(req, 'force_auth', {}, function (err, params, oauthConfig) {
Expand Down Expand Up @@ -282,8 +309,26 @@ module.exports = function (app, db) {
return res.send(r ? r.status : 400, err || body);
}
var profile = JSON.parse(body);
var accountAal2 = !!profile.twoFactorAuthentication;

// Passkey-only account with required profile AAL2: bounce
// once to FxA to force TOTP enrolment. The bounced flag
// caps it at one pass if the FxA divert is broken.
if (
req.session.profileAal2Required &&
!accountAal2 &&
!req.session.profileAal2Bounced
) {
req.session.profileAal2Bounced = true;
return res.redirect('/api/profile_aal2');
}

req.session.email = profile.email;
req.session.subscriptions = profile.subscriptions;
req.session.account_aal2 = accountAal2;
req.session.profileAal2Required = null;
req.session.profileAal2Bounced = null;

// ensure the redirect goes to the correct place for either
// the redirect or iframe OAuth flows.
var referrer = req.get('referrer') || '';
Expand Down
1 change: 1 addition & 0 deletions packages/123done/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ app.get('/api/auth_status', function (req, res) {
subscriptions: req.session.subscriptions || [],
amr: req.session.amr || null,
acr: req.session.acr || '0',
account_aal2: req.session.account_aal2 || false,
keys_jwe: req.session.keys_jwe || null,
})
);
Expand Down
6 changes: 6 additions & 0 deletions packages/123done/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
>
Sign In (Require 2FA)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the difference as Require AAL2 session vs Require 2FA-enabled account, but not sure if this would be clear for others? Either option feels like it needs more explanation 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the labels might be reversed? This one here requires an AAL2 session and the one below require 2FA-enabled account

</button>
<button
class="btn btn-large btn-info btn-persona profile-aal2"
type="submit"
>
Sign In (Require AAL2 Session)
</button>
<button
class="btn btn-large btn-info btn-persona third-party"
type="submit"
Expand Down
8 changes: 8 additions & 0 deletions packages/123done/static/js/123done.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ $(document).ready(function () {
if (loggedInState.acr === 'AAL2') {
loggedInEmail += ' ' + String.fromCodePoint(0x1f512);
}
// Account-level AAL2 marker, distinct from the session AAL2 lock icon.
if (loggedInState.account_aal2) {
loggedInEmail += ' ' + String.fromCodePoint(0x1f6e1);
}

function updateUI(email) {
$('ul.loginarea li').css('display', 'none');
Expand Down Expand Up @@ -239,6 +243,10 @@ $(document).ready(function () {
authenticate('two_step_authentication');
});

$('button.profile-aal2').click(function (ev) {
authenticate('profile_aal2');
});

$('button.third-party').click(function (ev) {
authenticate('best_choice', {
forceExperiment: 'thirdPartyAuth',
Expand Down
30 changes: 28 additions & 2 deletions packages/functional-tests/lib/passkeyPolyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const WEBAUTHN_DOM_EXCEPTION_NAMES = [
'UnknownError',
];

type Mode = 'pending' | 'success' | 'cancel';
type Mode = 'pending' | 'success' | 'cancel' | 'corrupt';
type Trigger = () => Promise<void>;
type PostCheck = () => Promise<void>;

Expand Down Expand Up @@ -173,6 +173,18 @@ export class PasskeyPolyfill {
}
}

/** Return an assertion with a tampered signature so the server rejects it. */
async corrupt(trigger: Trigger, postCheck: PostCheck) {
const previous = this.mode;
this.mode = 'corrupt';
try {
await trigger();
await postCheck();
} finally {
this.mode = previous;
}
}

/**
* Number of credentials the VirtualAuthenticator has minted during the
* lifetime of this polyfill. Tests use this to assert registration happened
Expand Down Expand Up @@ -233,11 +245,18 @@ export class PasskeyPolyfill {
}

const rpId = options.rpId ?? new URL(origin).hostname;
return VirtualAuthenticator.createAssertionResponse(cred, {
const assertion = VirtualAuthenticator.createAssertionResponse(cred, {
challenge: options.challenge,
origin,
rpId,
});

if (this.mode === 'corrupt') {
assertion.response.signature = tamperBase64UrlByte(
assertion.response.signature
);
}
return assertion;
}

private pickCredential(allow?: Array<{ id: string }>) {
Expand Down Expand Up @@ -275,6 +294,13 @@ function makeDomExceptionLike(name: string, message: string) {
return err;
}

/** Flip a byte of a base64url-encoded buffer to produce an invalid signature. */
function tamperBase64UrlByte(b64url: string): string {
const buf = Buffer.from(b64url, 'base64url');
buf[0] ^= 0xff;
return buf.toString('base64url');
}

/**
* Injected into the page. Replaces window.PublicKeyCredential with a stub
* class so (a) `PublicKeyCredential.parseCreationOptionsFromJSON` and the
Expand Down
9 changes: 5 additions & 4 deletions packages/functional-tests/lib/testAccountTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,15 @@ export class TestAccountTracker {

/**
* Creates a passwordless account via API (verifierSetAt: 0, no password).
* Used for testing signin to existing passwordless accounts.
* Note: The account is created WITHOUT a password to remain passwordless-eligible.
* Cleanup will set a password before destroying the account.
* @returns Partial credentials with email, uid, and sessionToken
* The returned `password` is generated for tests that subsequently drive
* the set-password flow, and is also used by cleanup to plant a password
* before destroying the account.
*/
async signUpPasswordless(): Promise<{
email: string;
uid: string;
sessionToken: string;
password: string;
}> {
const email = this.generateEmail(EmailPrefix.PASSWORDLESS);
const password = this.generatePassword();
Expand Down Expand Up @@ -291,6 +291,7 @@ export class TestAccountTracker {
email,
uid: result.uid,
sessionToken: result.sessionToken,
password,
};
}

Expand Down
15 changes: 15 additions & 0 deletions packages/functional-tests/pages/relier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,19 @@ export class RelierPage extends BaseLayout {
await this.page.getByText('Sign In (Require 2FA)').click();
return this.page.waitForURL(`${this.target.contentServerUrl}/**`);
}

async clickRequireProfileAAL2() {
await this.page.getByText('Sign In (Require AAL2 Session)').click();
return this.page.waitForURL(`${this.target.contentServerUrl}/**`);
}

async hasAccountAAL2Badge() {
const text = await this.page.locator('#loggedin span').innerText();
return text.includes(String.fromCodePoint(0x1f6e1));
}

async hasSessionAAL2Badge() {
const text = await this.page.locator('#loggedin span').innerText();
return text.includes(String.fromCodePoint(0x1f512));
}
}
Loading