mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user