feat(server): passkey pre-refactor (#15060)

#### PR Dependency Tree


* **PR #15060** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* OpenApp native sign-in and native session exchange (JWT) for mobile &
desktop.
  * Centralized short-lived auth challenge store for one-time tokens.
* Encrypted per-endpoint token storage and native token handlers
(Android, iOS, Electron).

* **Improvements**
* Richer auth-method reporting (password, magic link, OAuth, passkey)
and improved sign-in flows.
* Hardened magic-link, OAuth, and session issuance; JWT-backed sessions
and websocket JWT support.
* UX tweaks: form-based password submit, OTP autocomplete, adjusted
captcha flow.

* **Bug Fixes**
  * Expanded tests and auth-state resets to avoid cross-test leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -3,68 +3,63 @@ import {
Controller,
HttpCode,
HttpStatus,
Logger,
Post,
type RawBodyRequest,
Req,
Res,
} from '@nestjs/common';
import { ConnectedAccount } from '@prisma/client';
import type { Request, Response } from 'express';
import {
ActionForbidden,
Config,
getClientVersionFromRequest,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider,
URLHelper,
UseNamedGuard,
} from '../../base';
import { AuthService, Public } from '../../core/auth';
import { Models } from '../../models';
import {
OAuthCallbackBodySchema,
OAuthPreflightBodySchema,
Public,
SessionIssuer,
} from '../../core/auth';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthService } from './service';
@Controller('/api/oauth')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor(
private readonly auth: AuthService,
private readonly sessionIssuer: SessionIssuer,
private readonly oauth: OAuthService,
private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory,
private readonly url: URLHelper,
private readonly config: Config
private readonly url: URLHelper
) {}
@Public()
@UseNamedGuard('version')
@Post('/preflight')
@HttpCode(HttpStatus.OK)
async preflight(
@Req() req: Request,
@Body('provider') unknownProviderName?: keyof typeof OAuthProviderName,
@Body('redirect_uri') redirectUri?: string,
@Body('client') client?: string,
@Body('client_nonce') clientNonce?: string
) {
if (!unknownProviderName) {
async preflight(@Req() req: Request, @Body() body?: unknown) {
const input = OAuthPreflightBodySchema.safeParse(body);
if (!input.success) {
const fields = new Set(input.error.issues.map(issue => issue.path[0]));
if (fields.has('client_nonce')) {
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
}
throw new MissingOauthQueryParameter({ name: 'provider' });
}
if (!clientNonce) {
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
}
const providerName = OAuthProviderName[unknownProviderName];
const {
provider: unknownProviderName,
redirect_uri: redirectUri,
client,
client_nonce: clientNonce,
} = input.data;
const providerName =
OAuthProviderName[unknownProviderName as keyof typeof OAuthProviderName];
const provider = this.providerFactory.get(providerName);
if (!provider) {
@@ -123,57 +118,38 @@ export class OAuthController {
async callback(
@Req() req: RawBodyRequest<Request>,
@Res() res: Response,
@Body('code') code?: string,
@Body('state') stateStr?: string,
@Body('client_nonce') clientNonce?: string
@Body() body?: unknown
) {
// TODO(@forehalo): refactor and remove deprecated code in 0.23
if (!code) {
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (!stateStr) {
const input = OAuthCallbackBodySchema.safeParse(body);
if (!input.success) {
const fields = new Set(input.error.issues.map(issue => issue.path[0]));
if (fields.has('code')) {
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (fields.has('state')) {
throw new MissingOauthQueryParameter({ name: 'state' });
}
throw new MissingOauthQueryParameter({ name: 'state' });
}
// NOTE(@forehalo): Apple sign in will directly post /callback, with `state` set at #L73
let rawState = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
rawState = JSON.parse(stateStr);
stateStr = rawState.state;
} catch {
/* noop */
}
}
const { code, state: stateStr, client_nonce: clientNonce } = input.data;
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const verified = await this.oauth.verifyCallback({
code,
stateStr,
clientNonce,
rawBody: req.rawBody,
});
const state = await this.oauth.getOAuthState(stateStr);
if (!state) {
throw new OauthStateExpired();
}
if (!state.token) {
state.token = stateStr;
}
if (
state.provider === OAuthProviderName.Apple &&
rawState &&
state.client &&
state.client !== 'web'
) {
const clientUrl = new URL(`${state.client}://authentication`);
if (verified.type === 'handoff') {
const clientUrl = new URL(`${verified.state.client}://authentication`);
clientUrl.searchParams.set('method', 'oauth');
clientUrl.searchParams.set(
'payload',
JSON.stringify({
state: stateStr,
state: verified.stateToken,
code,
provider: rawState.provider,
provider: verified.provider,
})
);
clientUrl.searchParams.set('server', this.url.requestOrigin);
@@ -185,46 +161,8 @@ export class OAuthController {
);
}
if (!state.provider) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
if (
state.provider !== OAuthProviderName.Apple &&
(!clientNonce || !state.clientNonce || state.clientNonce !== clientNonce)
) {
throw new InvalidAuthState();
}
let tokens: Tokens;
try {
tokens = await provider.getToken(code, state);
} catch (err) {
let rayBodyString = '';
if (req.rawBody) {
// only log the first 4096 bytes of the raw body
rayBodyString = req.rawBody.subarray(0, 4096).toString('utf-8');
}
this.logger.warn(
`Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rayBodyString}, error: ${err}`
);
throw err;
}
const externAccount = await provider.getUser(tokens, state);
const user = await this.getOrCreateUserFromOauth(
state.provider,
externAccount,
tokens
);
await this.auth.setCookies(req, res, user.id, state.clientVersion);
const { identity, state } = verified;
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
if (
state.provider === OAuthProviderName.Apple &&
@@ -234,96 +172,9 @@ export class OAuthController {
}
res.send({
id: user.id,
id: identity.userId,
exchangeCode,
redirectUri: state.redirectUri,
});
}
private async getOrCreateUserFromOauth(
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
// already connected
await this.updateConnectedAccount(connectedAccount, tokens);
if (
!connectedAccount.user.emailVerifiedAt &&
// external email may change, check if it matches exists email
externalAccount.email.toLowerCase() ===
connectedAccount.user.email.toLowerCase()
) {
await this.auth.setEmailVerified(connectedAccount.userId);
}
return connectedAccount.user;
}
if (!this.config.auth.allowSignupForOauth) {
throw new SignUpForbidden();
}
const user = await this.models.user.fulfill(externalAccount.email, {
name: externalAccount.name,
avatarUrl: externalAccount.avatarUrl,
});
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
return user;
}
private async updateConnectedAccount(
connectedAccount: ConnectedAccount,
tokens: Tokens
) {
return await this.models.user.updateConnectedAccount(connectedAccount.id, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
/**
* we currently don't support connect oauth account to existing user
* keep it incase we need it in the future
*/
// @ts-expect-error allow unused
private async _connectAccount(
user: { id: string },
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
if (connectedAccount.userId !== user.id) {
throw new OauthAccountAlreadyConnected();
}
} else {
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
}
}
@@ -3,7 +3,7 @@ import './config';
import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core';
import { AuthModule } from '../../core/auth';
import { AUTH_OAUTH_PROVIDER_READER, AuthModule } from '../../core/auth';
import { UserModule } from '../../core/user';
import { OAuthController } from './controller';
import { OAuthProviderFactory } from './factory';
@@ -15,6 +15,7 @@ import { OAuthService } from './service';
imports: [AuthModule, UserModule, ServerConfigModule],
providers: [
OAuthProviderFactory,
{ provide: AUTH_OAUTH_PROVIDER_READER, useExisting: OAuthProviderFactory },
OAuthService,
OAuthResolver,
...OAuthProviders,
@@ -1,18 +1,57 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccount } from '@prisma/client';
import { SessionCache } from '../../base';
import {
Config,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider,
} from '../../base';
import {
AuthChallengeStore,
AuthService,
OAuthStateEnvelopeSchema,
type VerifiedIdentity,
} from '../../core/auth';
import { Models } from '../../models';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthPkceChallenge, OAuthState } from './types';
const OAUTH_STATE_KEY = 'OAUTH_STATE';
type HandoffResult = {
type: 'handoff';
code: string;
provider: unknown;
state: OAuthState;
stateToken: string;
};
type IdentityResult = {
type: 'identity';
identity: VerifiedIdentity;
state: OAuthState;
};
type VerifyCallbackResult = HandoffResult | IdentityResult;
const OAUTH_STATE_TTL_MS = 3600 * 3 * 1000;
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
private readonly providerFactory: OAuthProviderFactory,
private readonly cache: SessionCache
private readonly challenges: AuthChallengeStore,
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config
) {}
isValidState(stateStr: string) {
@@ -20,23 +59,191 @@ export class OAuthService {
}
async saveOAuthState(state: OAuthState) {
const token = randomUUID();
const payload: OAuthState = { ...state, token };
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, {
ttl: 3600 * 3 * 1000 /* 3 hours */,
});
return token;
return this.challenges.create<OAuthState>(
'oauth_state',
token => ({ ...state, token }),
OAUTH_STATE_TTL_MS
);
}
async getOAuthState(token: string) {
return this.cache.get<OAuthState>(`${OAUTH_STATE_KEY}:${token}`);
return this.challenges.get<OAuthState>('oauth_state', token);
}
availableOAuthProviders() {
return this.providerFactory.providers;
}
async verifyCallback(input: {
code: string;
stateStr: string;
clientNonce?: string;
rawBody?: Buffer;
}): Promise<VerifyCallbackResult> {
let stateStr = input.stateStr;
let rawState: { state: string; provider?: string } | null = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
const parsed = OAuthStateEnvelopeSchema.safeParse(JSON.parse(stateStr));
if (parsed.success) {
rawState = parsed.data;
stateStr = rawState.state;
}
} catch {} // noop
}
if (typeof stateStr !== 'string' || !this.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const state = await this.getOAuthState(stateStr);
if (!state) throw new OauthStateExpired();
if (!state.token) state.token = stateStr;
if (
state.provider === OAuthProviderName.Apple &&
rawState &&
state.client &&
state.client !== 'web'
) {
return {
type: 'handoff',
code: input.code,
provider: rawState.provider,
state,
stateToken: stateStr,
};
}
if (!state.provider) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
if (
state.provider !== OAuthProviderName.Apple &&
(!input.clientNonce ||
!state.clientNonce ||
state.clientNonce !== input.clientNonce)
) {
throw new InvalidAuthState();
}
return {
type: 'identity',
identity: await this.verifyCallbackIdentity(
input.code,
state,
stateStr,
input.rawBody
),
state,
};
}
async verifyCallbackIdentity(
code: string,
state: OAuthState,
stateStr: string,
rawBody?: Buffer
): Promise<VerifiedIdentity> {
if (!state.provider) {
throw new UnknownOauthProvider({ name: 'unknown' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider });
}
let tokens: Tokens;
try {
tokens = await provider.getToken(code, state);
} catch (err) {
const rawBodyString = rawBody
? rawBody.subarray(0, 4096).toString('utf-8')
: '';
this.logger.warn(
`Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rawBodyString}, error: ${err}`
);
throw err;
}
const externalAccount = await provider.getUser(tokens, state);
const user = await this.getOrCreateUserFromOauth(
state.provider,
externalAccount,
tokens
);
return {
userId: user.id,
method: 'oauth',
clientVersion: state.clientVersion,
};
}
private async getOrCreateUserFromOauth(
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
await this.updateConnectedAccount(connectedAccount, tokens);
if (
!connectedAccount.user.emailVerifiedAt &&
externalAccount.email.toLowerCase() ===
connectedAccount.user.email.toLowerCase()
) {
await this.auth.setEmailVerified(connectedAccount.userId);
}
return connectedAccount.user;
}
if (!this.config.auth.allowSignupForOauth) {
throw new SignUpForbidden();
}
const user = await this.models.user.fulfill(externalAccount.email, {
name: externalAccount.name,
avatarUrl: externalAccount.avatarUrl,
});
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
return user;
}
private async updateConnectedAccount(
connectedAccount: ConnectedAccount,
tokens: Tokens
) {
return await this.models.user.updateConnectedAccount(connectedAccount.id, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
createPkcePair(): OAuthPkceChallenge {
const codeVerifier = this.randomBase64Url(96);
const hash = createHash('sha256').update(codeVerifier).digest();