diff --git a/app/api/auth/confirmIdentity.ts b/app/api/auth/confirmIdentity.ts index 746b62d2..95ddddc2 100644 --- a/app/api/auth/confirmIdentity.ts +++ b/app/api/auth/confirmIdentity.ts @@ -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. @@ -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', }); diff --git a/app/api/auth/passkeys/PasskeyUtils.ts b/app/api/auth/passkeys/PasskeyUtils.ts index 3e6a37a0..0ed2a77a 100644 --- a/app/api/auth/passkeys/PasskeyUtils.ts +++ b/app/api/auth/passkeys/PasskeyUtils.ts @@ -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'; @@ -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. */ @@ -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 { +export async function retrieveCredentials(user: UserLike, rpID: string): Promise { return db.selectFrom(tUsersPasskeys) .select({ passkeyId: tUsersPasskeys.userPasskeyId, @@ -75,8 +84,9 @@ export async function retrieveCredentials(user: UserLike): Promise 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(); } @@ -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 + user: UserLike, rpID: string, name: string | undefined, registration: PasskeyRegistration) + : Promise { 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), diff --git a/app/api/auth/passkeys/createChallenge.ts b/app/api/auth/passkeys/createChallenge.ts index 2b708a50..55f86db7 100644 --- a/app/api/auth/passkeys/createChallenge.ts +++ b/app/api/auth/passkeys/createChallenge.ts @@ -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. @@ -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}`, diff --git a/app/api/auth/passkeys/listPasskeys.ts b/app/api/auth/passkeys/listPasskeys.ts index dcb7dcf6..2715f341 100644 --- a/app/api/auth/passkeys/listPasskeys.ts +++ b/app/api/auth/passkeys/listPasskeys.ts @@ -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. @@ -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}`; diff --git a/app/api/auth/passkeys/registerPasskey.ts b/app/api/auth/passkeys/registerPasskey.ts index 4cd94a54..d2d5582c 100644 --- a/app/api/auth/passkeys/registerPasskey.ts +++ b/app/api/auth/passkeys/registerPasskey.ts @@ -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'; /** @@ -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}`); @@ -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({ diff --git a/app/api/auth/signInPasskey.ts b/app/api/auth/signInPasskey.ts index 4d225be4..4cd89851 100644 --- a/app/api/auth/signInPasskey.ts +++ b/app/api/auth/signInPasskey.ts @@ -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. @@ -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}`); @@ -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 }); diff --git a/app/lib/database/scheme/UsersPasskeysTable.ts b/app/lib/database/scheme/UsersPasskeysTable.ts index 72ddf0f0..ce52faad 100644 --- a/app/lib/database/scheme/UsersPasskeysTable.ts +++ b/app/lib/database/scheme/UsersPasskeysTable.ts @@ -22,6 +22,7 @@ export class UsersPasskeysTable extends Table('credential_id', 'custom', 'Blob', BlobTypeAdapter); + credentialRpid = this.column('credential_rpid', 'string'); credentialName = this.optionalColumnWithDefaultValue('credential_name', 'string'); credentialCreated = this.columnWithDefaultValue('credential_created', 'customLocalDateTime', 'dateTime', TemporalTypeAdapter); credentialOrigin = this.column('credential_origin', 'string'); diff --git a/ts-sql.scheme.yaml b/ts-sql.scheme.yaml index 6154f221..05f68779 100644 --- a/ts-sql.scheme.yaml +++ b/ts-sql.scheme.yaml @@ -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: "" @@ -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 @@ -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,