Skip to content

Commit

Permalink
Strongly associate passkeys with an RpID
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Apr 16, 2024
1 parent f32dffe commit 2c9cdc5
Show file tree
Hide file tree
Showing 8 changed files with 50 additions and 23 deletions.
8 changes: 4 additions & 4 deletions app/api/auth/confirmIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import { z } from 'zod';
import type { ActionProps } from '../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../Types';
import { Log, LogType, LogSeverity } from '@lib/Log';
import { determineRpID, retrieveCredentials, storeUserChallenge } from './passkeys/PasskeyUtils';
import { isValidActivatedUser } from '@lib/auth/Authentication';
import { retrieveCredentials } from './passkeys/PasskeyUtils';
import { storeUserChallenge } from './passkeys/PasskeyUtils';

/**
* Interface definition for the ConfirmIdentity API, exposed through /api/auth/confirm-identity.
Expand Down Expand Up @@ -69,15 +68,16 @@ export async function confirmIdentity(request: Request, props: ActionProps): Pro
return { success: false };

let authenticationOptions = undefined;
const rpID = determineRpID(props);

const credentials = await retrieveCredentials(user);
const credentials = await retrieveCredentials(user, rpID);
if (credentials.length > 0) {
authenticationOptions = await generateAuthenticationOptions({
allowCredentials: credentials.map(credential => ({
id: isoBase64URL.fromBuffer(credential.credentialId),
// TODO: `transports`?
})),
rpID: props.origin.replace(/\:.*?$/g, ''), // must be a domain
rpID,
userVerification: 'preferred',
});

Expand Down
20 changes: 16 additions & 4 deletions app/api/auth/passkeys/PasskeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';

import type { ActionProps } from '@app/api/Action';
import type { Temporal } from '@lib/Temporal';
import db, { tUsersPasskeys, tUsers } from '@lib/database';

Expand All @@ -20,6 +21,13 @@ export async function deleteCredential(user: UserLike, passkeyId: number): Promi
.executeDelete() > 0;
}

/**
* Determines the RpID associated with the request for which `props` was created.
*/
export function determineRpID(props: ActionProps): string {
return props.origin.replace(/\:.*?$/g, '');
}

/**
* Description of a credential within our system.
*/
Expand Down Expand Up @@ -61,9 +69,10 @@ export interface Credential {
}

/**
* Retrieves the credentials associated with the given `user`.
* Retrieves the credentials associated with the given `user` that are stored for the `rpID`, which
* is the origin the Volunteer Manager is presently running on.
*/
export async function retrieveCredentials(user: UserLike): Promise<Credential[]> {
export async function retrieveCredentials(user: UserLike, rpID: string): Promise<Credential[]> {
return db.selectFrom(tUsersPasskeys)
.select({
passkeyId: tUsersPasskeys.userPasskeyId,
Expand All @@ -75,8 +84,9 @@ export async function retrieveCredentials(user: UserLike): Promise<Credential[]>
lastUsed: tUsersPasskeys.credentialLastUsed,
})
.where(tUsersPasskeys.userId.equals(user.userId))
.and(tUsersPasskeys.credentialRpid.equals(rpID))
.orderBy(tUsersPasskeys.credentialLastUsed, 'desc nulls last')
.orderBy(tUsersPasskeys.credentialCreated, 'asc')
.orderBy(tUsersPasskeys.credentialCreated, 'asc')
.executeSelectMany();
}

Expand All @@ -101,12 +111,14 @@ export async function updateCredentialCounter(
* Stores the given `registration` in the database associated with the `user`.
*/
export async function storePasskeyRegistration(
user: UserLike, name: string | undefined, registration: PasskeyRegistration): Promise<void>
user: UserLike, rpID: string, name: string | undefined, registration: PasskeyRegistration)
: Promise<void>
{
await db.insertInto(tUsersPasskeys)
.set({
userId: user.userId,
credentialId: Buffer.from(registration.credentialID),
credentialRpid: rpID,
credentialName: name,
credentialOrigin: registration.origin,
credentialPublicKey: Buffer.from(registration.credentialPublicKey),
Expand Down
8 changes: 5 additions & 3 deletions app/api/auth/passkeys/createChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { z } from 'zod';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { determineEnvironment } from '@lib/Environment';
import { noAccess, type ActionProps } from '../../Action';
import { retrieveCredentials, storeUserChallenge } from './PasskeyUtils';
import { determineRpID, retrieveCredentials, storeUserChallenge } from './PasskeyUtils';

/**
* Interface definition for the Passkeys API, exposed through /api/auth/passkeys.
Expand Down Expand Up @@ -52,11 +52,13 @@ export async function createChallenge(request: Request, props: ActionProps): Pro
if (!environment)
notFound();

const credentials = await retrieveCredentials(props.user) ?? [];
const rpID = determineRpID(props);

const credentials = await retrieveCredentials(props.user, rpID) ?? [];

const options = await generateRegistrationOptions({
rpName: `AnimeCon ${environment.environmentTitle}`,
rpID: props.origin.replace(/\:.*?$/g, ''), // must be a domain
rpID,
userID: isoUint8Array.fromUTF8String(`${props.user.userId}`),
userName: props.user.username,
userDisplayName: `${props.user.firstName} ${props.user.lastName}`,
Expand Down
6 changes: 4 additions & 2 deletions app/api/auth/passkeys/listPasskeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { type ActionProps, noAccess } from '../../Action';
import { determineEnvironment } from '@lib/Environment';
import { formatDate } from '@lib/Temporal';
import { retrieveCredentials } from './PasskeyUtils';
import { determineRpID, retrieveCredentials } from './PasskeyUtils';

/**
* Interface definition for the Passkeys API, exposed through /api/auth/passkeys.
Expand Down Expand Up @@ -65,7 +65,9 @@ export async function listPasskeys(request: Request, props: ActionProps): Promis
if (!environment)
notFound();

const credentials = await retrieveCredentials(props.user);
const rpID = determineRpID(props);

const credentials = await retrieveCredentials(props.user, rpID);
const passkeys = credentials.map(credential => {
const label = credential.name ?? `Passkey #${credential.passkeyId}`;

Expand Down
11 changes: 7 additions & 4 deletions app/api/auth/passkeys/registerPasskey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { type ActionProps, noAccess } from '../../Action';
import { LogSeverity, LogType, Log } from '@lib/Log';
import { getEnvironmentIterator } from '@lib/Environment';
import { retrieveUserChallenge, storePasskeyRegistration, storeUserChallenge }
import { determineRpID, retrieveUserChallenge, storePasskeyRegistration, storeUserChallenge }
from './PasskeyUtils';

/**
Expand Down Expand Up @@ -66,8 +66,9 @@ export async function registerPasskey(request: Request, props: ActionProps): Pro
if (!expectedChallenge)
return { success: false, error: 'You are not currently in a passkey registration flow' };

const rpID = determineRpID(props);

const environments = [ ...await getEnvironmentIterator() ];
const environmentDomains = environments.map(environment => environment.environmentName);
const environmentOrigins = environments.map(environment =>
`https://${environment.environmentName}`);

Expand All @@ -76,13 +77,15 @@ export async function registerPasskey(request: Request, props: ActionProps): Pro
response: request.registration,
expectedChallenge,
expectedOrigin: [ ...environmentOrigins, kLocalDevelopmentOrigin ],
expectedRPID: [ ...environmentDomains, kLocalDevelopmentDomain ],
expectedRPID: rpID,
});

if (!verification.verified || !verification.registrationInfo)
return { success: false, error: 'The passkey registration could not be verified' };

await storePasskeyRegistration(props.user, request.name, verification.registrationInfo);
await storePasskeyRegistration(
props.user, rpID, request.name, verification.registrationInfo);

await storeUserChallenge(props.user, /* reset= */ null);

await Log({
Expand Down
11 changes: 6 additions & 5 deletions app/api/auth/signInPasskey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { Log, LogType, LogSeverity } from '@lib/Log';
import { getEnvironmentIterator } from '@lib/Environment';
import { getUserSessionToken } from '@lib/auth/Authentication';
import { isValidActivatedUser } from '@lib/auth/Authentication';
import { retrieveCredentials, retrieveUserChallenge, storeUserChallenge, updateCredentialCounter }
import { determineRpID, retrieveCredentials, retrieveUserChallenge, storeUserChallenge, updateCredentialCounter }
from './passkeys/PasskeyUtils';
import { writeSealedSessionCookie } from '@lib/auth/Session';

import { kLocalDevelopmentDomain, kLocalDevelopmentOrigin } from './passkeys/registerPasskey';
import { kLocalDevelopmentOrigin } from './passkeys/registerPasskey';

/**
* Interface definition for the SignIn API, exposed through /api/auth/sign-in-passkey.
Expand Down Expand Up @@ -75,15 +75,16 @@ export async function signInPasskey(request: Request, props: ActionProps): Promi
if (!user)
return { success: false };

const rpID = determineRpID(props);

const challenge = await retrieveUserChallenge(user);
const credentials = await retrieveCredentials(user);
const credentials = await retrieveCredentials(user, rpID);
const sessionToken = await getUserSessionToken(user);

if (!challenge || !credentials.length || !sessionToken)
return { success: false, error: 'Unable to load the challenge and credential' };

const environments = [ ...await getEnvironmentIterator() ];
const environmentDomains = environments.map(environment => environment.environmentName);
const environmentOrigins = environments.map(environment =>
`https://${environment.environmentName}`);

Expand Down Expand Up @@ -112,7 +113,7 @@ export async function signInPasskey(request: Request, props: ActionProps): Promi
response: request.verification!,
expectedChallenge: challenge,
expectedOrigin: [ ...environmentOrigins, kLocalDevelopmentOrigin ],
expectedRPID: [ ...environmentDomains, kLocalDevelopmentDomain ],
expectedRPID: rpID,
authenticator
});

Expand Down
1 change: 1 addition & 0 deletions app/lib/database/scheme/UsersPasskeysTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class UsersPasskeysTable extends Table<DBConnection, 'UsersPasskeysTable'
userPasskeyId = this.autogeneratedPrimaryKey('user_passkey_id', 'int');
userId = this.column('user_id', 'int');
credentialId = this.column<Buffer>('credential_id', 'custom', 'Blob', BlobTypeAdapter);
credentialRpid = this.column('credential_rpid', 'string');
credentialName = this.optionalColumnWithDefaultValue('credential_name', 'string');
credentialCreated = this.columnWithDefaultValue<ZonedDateTime>('credential_created', 'customLocalDateTime', 'dateTime', TemporalTypeAdapter);
credentialOrigin = this.column('credential_origin', 'string');
Expand Down
8 changes: 7 additions & 1 deletion ts-sql.scheme.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ tables:
`webhook_request_headers` text NOT NULL,
`webhook_request_body` text NOT NULL,
PRIMARY KEY (`webhook_call_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
- name: displays
type: BASE TABLE
comment: ""
Expand Down Expand Up @@ -1914,6 +1914,11 @@ tables:
nullable: false
default: null
comment: ""
- name: credential_rpid
type: varchar(64)
nullable: false
default: null
comment: ""
- name: credential_name
type: text
nullable: true
Expand Down Expand Up @@ -1983,6 +1988,7 @@ tables:
`user_passkey_id` int(8) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(8) unsigned NOT NULL,
`credential_id` blob NOT NULL,
`credential_rpid` varchar(64) NOT NULL,
`credential_name` text DEFAULT NULL,
`credential_created` datetime NOT NULL DEFAULT current_timestamp(),
`credential_origin` varchar(64) NOT NULL,
Expand Down

0 comments on commit 2c9cdc5

Please sign in to comment.