mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-07 04:20:11 +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:
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { InvalidAuthState, SessionCache } from '../../base';
|
||||
import { isValidCacheTtl } from '../../base/cache/provider';
|
||||
|
||||
export type AuthChallengePurpose =
|
||||
| 'oauth_state'
|
||||
| 'open_app_sign_in'
|
||||
| 'native_session_exchange'
|
||||
| 'captcha'
|
||||
| 'passkey_registration'
|
||||
| 'passkey_authentication';
|
||||
|
||||
@Injectable()
|
||||
export class AuthChallengeStore {
|
||||
constructor(private readonly cache: SessionCache) {}
|
||||
|
||||
async create<T>(
|
||||
purpose: AuthChallengePurpose,
|
||||
payload: T | ((token: string) => T),
|
||||
ttlMs: number
|
||||
): Promise<string> {
|
||||
if (!isValidCacheTtl(ttlMs)) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
const value =
|
||||
typeof payload === 'function'
|
||||
? (payload as (token: string) => T)(token)
|
||||
: payload;
|
||||
const stored = await this.cache.set(this.key(purpose, token), value, {
|
||||
ttl: ttlMs,
|
||||
});
|
||||
if (!stored) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async get<T>(purpose: AuthChallengePurpose, token: string) {
|
||||
return (await this.cache.get<T>(this.key(purpose, token))) ?? null;
|
||||
}
|
||||
|
||||
async consume<T>(purpose: AuthChallengePurpose, token: string) {
|
||||
return (await this.cache.getAndDelete<T>(this.key(purpose, token))) ?? null;
|
||||
}
|
||||
|
||||
private key(purpose: AuthChallengePurpose, token: string) {
|
||||
return `auth_challenge:${purpose}:${token}`;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveMx, resolveTxt, setServers } from 'node:dns/promises';
|
||||
import { setServers } from 'node:dns/promises';
|
||||
|
||||
import {
|
||||
Body,
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Get,
|
||||
Header,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -16,27 +15,33 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EmailTokenNotFound,
|
||||
getRequestCookie,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
InvalidEmailToken,
|
||||
SignUpForbidden,
|
||||
Throttle,
|
||||
URLHelper,
|
||||
UseNamedGuard,
|
||||
WrongSignInCredentials,
|
||||
} from '../../base';
|
||||
import { Models, TokenType } from '../../models';
|
||||
import { Models } from '../../models';
|
||||
import { validators } from '../utils/validators';
|
||||
import { Public } from './guard';
|
||||
import { AuthService } from './service';
|
||||
import { MagicLinkAuthService } from './magic-link';
|
||||
import { AuthMethodsService } from './methods';
|
||||
import { SessionExchangeService } from './native-exchange';
|
||||
import { OpenAppAuthService } from './open-app';
|
||||
import { AuthService, sessionUser } from './service';
|
||||
import { CurrentUser, Session } from './session';
|
||||
import { SessionIssuer } from './session-issuer';
|
||||
|
||||
interface PreflightResponse {
|
||||
registered: boolean;
|
||||
hasPassword: boolean;
|
||||
methods: {
|
||||
password: { available: boolean };
|
||||
magicLink: { available: boolean };
|
||||
oauth: { available: boolean; providers: string[] };
|
||||
passkey: { available: boolean; discoverable: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
interface SignInCredential {
|
||||
@@ -56,17 +61,25 @@ interface OpenAppSignInCredential {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface NativeSessionExchangeCredential {
|
||||
code: string;
|
||||
}
|
||||
|
||||
type SignInResponse = CurrentUser & {
|
||||
exchangeCode?: string;
|
||||
};
|
||||
|
||||
@Throttle('strict')
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly crypto: CryptoHelper
|
||||
private readonly sessionIssuer: SessionIssuer,
|
||||
private readonly magicLink: MagicLinkAuthService,
|
||||
private readonly openApp: OpenAppAuthService,
|
||||
private readonly authMethods: AuthMethodsService,
|
||||
private readonly sessionExchange: SessionExchangeService,
|
||||
private readonly models: Models
|
||||
) {
|
||||
if (env.dev) {
|
||||
// set DNS servers in dev mode
|
||||
@@ -89,19 +102,13 @@ export class AuthController {
|
||||
}
|
||||
validators.assertValidEmail(params.email);
|
||||
|
||||
const user = await this.models.user.getUserByEmail(params.email);
|
||||
return this.authMethods.loginPreflight(params.email);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
registered: false,
|
||||
hasPassword: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
registered: user.registered,
|
||||
hasPassword: !!user.password,
|
||||
};
|
||||
@UseNamedGuard('version')
|
||||
@Get('/methods')
|
||||
async boundMethods(@CurrentUser() user: CurrentUser) {
|
||||
return this.authMethods.boundMethods(user.id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@@ -142,10 +149,17 @@ export class AuthController {
|
||||
email: string,
|
||||
password: string
|
||||
) {
|
||||
const user = await this.auth.signIn(email, password);
|
||||
const identity = await this.auth.verifyPassword(email, password);
|
||||
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
res.status(HttpStatus.OK).send(user);
|
||||
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
|
||||
const user = await this.models.user.get(identity.userId);
|
||||
if (!user) {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
res.status(HttpStatus.OK).send({
|
||||
...sessionUser(user),
|
||||
exchangeCode,
|
||||
} satisfies SignInResponse);
|
||||
}
|
||||
|
||||
async sendMagicLink(
|
||||
@@ -154,105 +168,10 @@ export class AuthController {
|
||||
callbackUrl = '/magic-link',
|
||||
clientNonce?: string
|
||||
) {
|
||||
if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const callbackUrlObj = this.url.url(callbackUrl);
|
||||
const redirectUriInCallback =
|
||||
callbackUrlObj.searchParams.get('redirect_uri');
|
||||
if (
|
||||
redirectUriInCallback &&
|
||||
!this.url.isAllowedRedirectUri(redirectUriInCallback)
|
||||
) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
// send email magic link
|
||||
const user = await this.models.user.getUserByEmail(email, {
|
||||
withDisabled: true,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!this.config.auth.allowSignup) {
|
||||
throw new SignUpForbidden();
|
||||
}
|
||||
|
||||
if (this.config.auth.requireEmailDomainVerification) {
|
||||
// verify domain has MX, SPF, DMARC records
|
||||
const [name, domain, ...rest] = email.split('@');
|
||||
if (rest.length || !domain) {
|
||||
throw new InvalidEmail({ email });
|
||||
}
|
||||
const [mx, spf, dmarc] = await Promise.allSettled([
|
||||
resolveMx(domain).then(t => t.map(mx => mx.exchange).filter(Boolean)),
|
||||
resolveTxt(domain).then(t =>
|
||||
t.map(([k]) => k).filter(txt => txt.includes('v=spf1'))
|
||||
),
|
||||
resolveTxt('_dmarc.' + domain).then(t =>
|
||||
t.map(([k]) => k).filter(txt => txt.includes('v=DMARC1'))
|
||||
),
|
||||
]).then(t => t.filter(t => t.status === 'fulfilled').map(t => t.value));
|
||||
if (!mx?.length || !spf?.length || !dmarc?.length) {
|
||||
throw new InvalidEmail({ email });
|
||||
}
|
||||
// filter out alias emails
|
||||
if (name.includes('+')) {
|
||||
throw new InvalidEmail({ email });
|
||||
}
|
||||
}
|
||||
} else if (user.disabled) {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
const ttlInSec = 30 * 60;
|
||||
const token = await this.models.verificationToken.create(
|
||||
TokenType.SignIn,
|
||||
email,
|
||||
ttlInSec
|
||||
);
|
||||
|
||||
const otp = this.crypto.otp();
|
||||
await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce);
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, { token: otp, email });
|
||||
if (env.dev) {
|
||||
// make it easier to test in dev mode
|
||||
this.logger.debug(`Magic link: ${magicLink}`);
|
||||
}
|
||||
|
||||
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
|
||||
|
||||
res.status(HttpStatus.OK).send({
|
||||
email: email,
|
||||
});
|
||||
const payload = await this.magicLink.send(email, callbackUrl, clientNonce);
|
||||
res.status(HttpStatus.OK).send(payload);
|
||||
}
|
||||
|
||||
@Public()
|
||||
/**
|
||||
* @deprecated Kept for 0.25 clients that still call GET `/api/auth/sign-out`.
|
||||
* Use POST `/api/auth/sign-out` instead.
|
||||
*/
|
||||
@Get('/sign-out')
|
||||
async signOutDeprecated(
|
||||
@Res() res: Response,
|
||||
@Session() session: Session | undefined,
|
||||
@Query('user_id') userId: string | undefined
|
||||
) {
|
||||
res.setHeader('Deprecation', 'true');
|
||||
|
||||
if (!session) {
|
||||
res.status(HttpStatus.OK).send({});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.auth.signOut(session.sessionId, userId);
|
||||
await this.auth.refreshCookies(res, session.sessionId);
|
||||
|
||||
res.status(HttpStatus.OK).send({});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('/sign-out')
|
||||
async signOut(
|
||||
@Req() req: Request,
|
||||
@@ -265,14 +184,15 @@ export class AuthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfCookie = req.cookies?.[AuthService.csrfCookieName] as
|
||||
| string
|
||||
| undefined;
|
||||
if (req.authType === 'jwt') {
|
||||
await this.auth.signOut(session.sessionId, session.user.id);
|
||||
res.status(HttpStatus.OK).send({});
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfCookie = getRequestCookie(req, AuthService.csrfCookieName);
|
||||
const csrfHeader = req.get('x-affine-csrf-token');
|
||||
if (
|
||||
csrfHeader && // optional for backward compatibility, drop after 0.25.0 outdated
|
||||
(!csrfCookie || csrfCookie !== csrfHeader)
|
||||
) {
|
||||
if (!csrfHeader || !csrfCookie || csrfCookie !== csrfHeader) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
@@ -286,17 +206,8 @@ export class AuthController {
|
||||
@UseNamedGuard('version')
|
||||
@Post('/open-app/sign-in-code')
|
||||
async openAppSignInCode(@CurrentUser() user?: CurrentUser) {
|
||||
if (!user) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
// short-lived one-time code for handing off the authenticated session
|
||||
const code = await this.models.verificationToken.create(
|
||||
TokenType.OpenAppSignIn,
|
||||
user.id,
|
||||
5 * 60
|
||||
);
|
||||
|
||||
if (!user) throw new ActionForbidden();
|
||||
const code = await this.openApp.createSignInCode(user);
|
||||
return { code };
|
||||
}
|
||||
|
||||
@@ -308,21 +219,21 @@ export class AuthController {
|
||||
@Res() res: Response,
|
||||
@Body() credential: OpenAppSignInCredential
|
||||
) {
|
||||
if (!credential?.code) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
if (!credential?.code) throw new InvalidAuthState();
|
||||
const identity = await this.openApp.verifySignInCode(credential.code);
|
||||
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
|
||||
res.send({ id: identity.userId, exchangeCode });
|
||||
}
|
||||
|
||||
const tokenRecord = await this.models.verificationToken.get(
|
||||
TokenType.OpenAppSignIn,
|
||||
credential.code
|
||||
);
|
||||
|
||||
if (!tokenRecord?.credential) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
await this.auth.setCookies(req, res, tokenRecord.credential);
|
||||
res.send({ id: tokenRecord.credential });
|
||||
@Public()
|
||||
@UseNamedGuard('version')
|
||||
@Post('/native/exchange')
|
||||
async exchangeSession(
|
||||
@Req() req: Request,
|
||||
@Body() credential: NativeSessionExchangeCredential
|
||||
) {
|
||||
if (!credential?.code) throw new InvalidAuthState();
|
||||
return await this.sessionExchange.exchange(req, credential.code);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@@ -334,42 +245,11 @@ export class AuthController {
|
||||
@Body()
|
||||
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
|
||||
) {
|
||||
if (!otp || !email) {
|
||||
throw new EmailTokenNotFound();
|
||||
}
|
||||
|
||||
if (!otp || !email) throw new EmailTokenNotFound();
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const consumed = await this.models.magicLinkOtp.consume(
|
||||
email,
|
||||
otp,
|
||||
clientNonce
|
||||
);
|
||||
if (!consumed.ok) {
|
||||
if (consumed.reason === 'nonce_mismatch') {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const token = consumed.token;
|
||||
|
||||
const tokenRecord = await this.models.verificationToken.verify(
|
||||
TokenType.SignIn,
|
||||
token,
|
||||
{
|
||||
credential: email,
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const user = await this.models.user.fulfill(email);
|
||||
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
res.send({ id: user.id });
|
||||
const identity = await this.magicLink.verify(email, otp, clientNonce);
|
||||
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
|
||||
res.send({ id: identity.userId, exchangeCode });
|
||||
}
|
||||
|
||||
@UseNamedGuard('version')
|
||||
@@ -377,24 +257,6 @@ export class AuthController {
|
||||
@Public()
|
||||
@Get('/session')
|
||||
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
|
||||
return {
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle('default', { limit: 1200 })
|
||||
@Public()
|
||||
@Get('/sessions')
|
||||
async currentSessionUsers(@Req() req: Request) {
|
||||
const token = req.cookies[AuthService.sessionCookieName];
|
||||
if (!token) {
|
||||
return {
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
users: await this.auth.getUserList(token),
|
||||
};
|
||||
return { user };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { resolveMx, resolveTxt } from 'node:dns/promises';
|
||||
|
||||
const EMAIL_DOMAIN_DNS_TIMEOUT_MS = 2_000;
|
||||
|
||||
type DomainLookups = {
|
||||
resolveMx: typeof resolveMx;
|
||||
resolveTxt: typeof resolveTxt;
|
||||
};
|
||||
|
||||
const defaultLookups: DomainLookups = {
|
||||
resolveMx,
|
||||
resolveTxt,
|
||||
};
|
||||
|
||||
function joinTxtRecords(records: string[][]) {
|
||||
return records.map(record => record.join(''));
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error('DNS lookup timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyEmailDomainRecords(
|
||||
email: string,
|
||||
lookups: DomainLookups = defaultLookups,
|
||||
timeoutMs = EMAIL_DOMAIN_DNS_TIMEOUT_MS
|
||||
) {
|
||||
const [name, domain, ...rest] = email.split('@');
|
||||
if (rest.length || !domain || name.includes('+')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [mx, spf, dmarc] = await Promise.allSettled([
|
||||
withTimeout(
|
||||
lookups
|
||||
.resolveMx(domain)
|
||||
.then(records => records.map(mx => mx.exchange).filter(Boolean)),
|
||||
timeoutMs
|
||||
),
|
||||
withTimeout(
|
||||
lookups
|
||||
.resolveTxt(domain)
|
||||
.then(records =>
|
||||
joinTxtRecords(records).filter(txt => txt.includes('v=spf1'))
|
||||
),
|
||||
timeoutMs
|
||||
),
|
||||
withTimeout(
|
||||
lookups
|
||||
.resolveTxt('_dmarc.' + domain)
|
||||
.then(records =>
|
||||
joinTxtRecords(records).filter(txt => txt.includes('v=DMARC1'))
|
||||
),
|
||||
timeoutMs
|
||||
),
|
||||
]).then(results =>
|
||||
results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
);
|
||||
|
||||
return !!mx?.length && !!spf?.length && !!dmarc?.length;
|
||||
}
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
UnsupportedClientVersion,
|
||||
} from '../../base';
|
||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||
import {
|
||||
extractTokenFromHeader,
|
||||
getSessionOptionsFromRequest,
|
||||
SessionIdSchema,
|
||||
} from './input';
|
||||
import { isLikelyJwt, JwtSessionService } from './jwt-session';
|
||||
import { AuthService } from './service';
|
||||
import { Session, TokenSession } from './session';
|
||||
|
||||
@@ -31,9 +37,16 @@ const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
||||
const INTERNAL_ACCESS_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
|
||||
|
||||
type AuthenticatedRequestSession =
|
||||
| { type: 'jwt'; session: Session }
|
||||
| { type: 'cookie_session'; session: Session }
|
||||
| { type: 'legacy_bearer_session'; session: Session }
|
||||
| { type: 'access_token'; token: TokenSession };
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
private jwtSession!: JwtSessionService;
|
||||
private readonly cachedVersionRange = new Map<string, semver.Range | null>();
|
||||
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
|
||||
private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)';
|
||||
@@ -48,6 +61,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
onModuleInit() {
|
||||
this.auth = this.ref.get(AuthService, { strict: false });
|
||||
this.jwtSession = this.ref.get(JwtSessionService, { strict: false });
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
@@ -110,12 +124,102 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | TokenSession | null> {
|
||||
const userSession = await this.signInWithCookie(req, res, isPublic);
|
||||
if (userSession) {
|
||||
return userSession;
|
||||
const result = await this.resolveRequestSession(req, res, isPublic);
|
||||
return result?.type === 'access_token'
|
||||
? result.token
|
||||
: (result?.session ?? null);
|
||||
}
|
||||
|
||||
private async resolveRequestSession(
|
||||
req: Request,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<AuthenticatedRequestSession | null> {
|
||||
const bearer = req.headers.authorization
|
||||
? extractTokenFromHeader(req.headers.authorization)
|
||||
: undefined;
|
||||
let ignoredInvalidPublicJwt = false;
|
||||
|
||||
if (bearer && isLikelyJwt(bearer)) {
|
||||
try {
|
||||
const session = await this.signInWithJwt(req, bearer, res, isPublic);
|
||||
return session ? { type: 'jwt', session } : null;
|
||||
} catch (err) {
|
||||
if (!isPublic) throw err;
|
||||
ignoredInvalidPublicJwt = true;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.signInWithAccessToken(req);
|
||||
if (bearer && !ignoredInvalidPublicJwt) {
|
||||
// Legacy auth compatibility: old clients may still send opaque session ids as bearer tokens.
|
||||
const legacyBearerSession = await this.signInWithSessionId(
|
||||
req,
|
||||
bearer,
|
||||
res,
|
||||
isPublic
|
||||
);
|
||||
if (legacyBearerSession) {
|
||||
return { type: 'legacy_bearer_session', session: legacyBearerSession };
|
||||
}
|
||||
const token = await this.signInWithAccessToken(req);
|
||||
return token ? { type: 'access_token', token } : null;
|
||||
}
|
||||
|
||||
const session = await this.signInWithCookie(req, res, isPublic);
|
||||
return session ? { type: 'cookie_session', session } : null;
|
||||
}
|
||||
|
||||
async signInWithJwt(
|
||||
req: Request,
|
||||
token: string,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | null> {
|
||||
if (req.session && req.authType === 'jwt') return req.session;
|
||||
const session = await this.jwtSession.verify(token);
|
||||
const versionAllowed = await this.checkUserSessionClientVersion(
|
||||
req,
|
||||
session,
|
||||
res,
|
||||
isPublic
|
||||
);
|
||||
if (!versionAllowed) return null;
|
||||
req.session = session;
|
||||
req.authType = 'jwt';
|
||||
return req.session;
|
||||
}
|
||||
|
||||
async signInWithSessionId(
|
||||
req: Request,
|
||||
sessionId: string,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | null> {
|
||||
if (req.session && req.session.sessionId === sessionId) return req.session;
|
||||
const parsedSessionId = SessionIdSchema.safeParse(sessionId);
|
||||
if (!parsedSessionId.success) return null;
|
||||
|
||||
const { userId } = getSessionOptionsFromRequest(req);
|
||||
const userSession = await this.auth.getUserSession(
|
||||
parsedSessionId.data,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!userSession) return null;
|
||||
req.session = { ...userSession.session, user: userSession.user };
|
||||
const versionAllowed = await this.checkUserSessionClientVersion(
|
||||
req,
|
||||
req.session,
|
||||
res,
|
||||
isPublic
|
||||
);
|
||||
if (!versionAllowed) {
|
||||
req.session = undefined;
|
||||
return null;
|
||||
}
|
||||
req.authType = 'session';
|
||||
|
||||
return req.session;
|
||||
}
|
||||
|
||||
async signInWithCookie(
|
||||
@@ -123,37 +227,24 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | null> {
|
||||
if (req.session) {
|
||||
return req.session;
|
||||
}
|
||||
if (req.session) return req.session;
|
||||
|
||||
// TODO(@forehalo): a cache for user session
|
||||
const userSession = await this.auth.getUserSessionFromRequest(req, res);
|
||||
|
||||
if (userSession) {
|
||||
const headerClientVersion = getClientVersionFromRequest(req);
|
||||
if (this.config.client.versionControl.enabled) {
|
||||
const clientVersion =
|
||||
headerClientVersion ??
|
||||
userSession.session.refreshClientVersion ??
|
||||
userSession.session.signInClientVersion;
|
||||
req.session = { ...userSession.session, user: userSession.user };
|
||||
|
||||
const versionCheckResult = this.checkClientVersion(clientVersion);
|
||||
if (!versionCheckResult.ok) {
|
||||
await this.auth.signOut(userSession.session.sessionId);
|
||||
if (res) {
|
||||
await this.auth.refreshCookies(res, userSession.session.sessionId);
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
requiredVersion: versionCheckResult.requiredVersion,
|
||||
});
|
||||
}
|
||||
const versionAllowed = await this.checkUserSessionClientVersion(
|
||||
req,
|
||||
req.session,
|
||||
res,
|
||||
isPublic
|
||||
);
|
||||
if (!versionAllowed) {
|
||||
req.session = undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
@@ -165,10 +256,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
req.session = {
|
||||
...userSession.session,
|
||||
user: userSession.user,
|
||||
};
|
||||
req.authType = 'session';
|
||||
|
||||
return req.session;
|
||||
}
|
||||
@@ -176,6 +264,42 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async checkUserSessionClientVersion(
|
||||
req: Request,
|
||||
session: Session,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
) {
|
||||
if (!this.config.client.versionControl.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerClientVersion = getClientVersionFromRequest(req);
|
||||
const clientVersion =
|
||||
headerClientVersion ??
|
||||
session.refreshClientVersion ??
|
||||
session.signInClientVersion;
|
||||
|
||||
const versionCheckResult = this.checkClientVersion(clientVersion);
|
||||
if (versionCheckResult.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.auth.signOut(session.sessionId);
|
||||
if (res) {
|
||||
await this.auth.refreshCookies(res, session.sessionId);
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
requiredVersion: versionCheckResult.requiredVersion,
|
||||
});
|
||||
}
|
||||
|
||||
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
|
||||
if (req.token) {
|
||||
return req.token;
|
||||
@@ -184,10 +308,8 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
|
||||
|
||||
if (tokenSession) {
|
||||
req.token = {
|
||||
...tokenSession.token,
|
||||
user: tokenSession.user,
|
||||
};
|
||||
req.token = { ...tokenSession.token, user: tokenSession.user };
|
||||
req.authType = 'access_token';
|
||||
|
||||
return req.token;
|
||||
}
|
||||
@@ -280,11 +402,9 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = {
|
||||
// compatibility with websocket request
|
||||
parseCookies(upgradeReq);
|
||||
|
||||
upgradeReq.cookies = {
|
||||
[AuthService.sessionCookieName]: handshake.auth.token,
|
||||
[AuthService.userCookieName]: handshake.auth.userId,
|
||||
...upgradeReq.cookies,
|
||||
};
|
||||
if (handshake.auth.tokenType === 'jwt') {
|
||||
upgradeReq.headers.authorization = `Bearer ${handshake.auth.token}`;
|
||||
}
|
||||
|
||||
const session = await (async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type AuthMethod =
|
||||
| 'password'
|
||||
| 'magic_link'
|
||||
| 'oauth'
|
||||
| 'open_app'
|
||||
| 'passkey';
|
||||
|
||||
export interface VerifiedIdentity {
|
||||
userId: string;
|
||||
method: AuthMethod;
|
||||
clientVersion?: string;
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import { FeatureModule } from '../features';
|
||||
import { MailModule } from '../mail';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthChallengeStore } from './challenge-store';
|
||||
import { AuthController } from './controller';
|
||||
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
|
||||
import { AuthCronJob } from './job';
|
||||
import { JwtSessionService } from './jwt-session';
|
||||
import { MagicLinkAuthService } from './magic-link';
|
||||
import { AuthMethodsService } from './methods';
|
||||
import { SessionExchangeService } from './native-exchange';
|
||||
import { OpenAppAuthService } from './open-app';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
import { SessionIssuer } from './session-issuer';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
|
||||
@@ -18,15 +25,40 @@ import { AuthService } from './service';
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
AuthGuard,
|
||||
JwtSessionService,
|
||||
SessionIssuer,
|
||||
AuthChallengeStore,
|
||||
MagicLinkAuthService,
|
||||
OpenAppAuthService,
|
||||
AuthMethodsService,
|
||||
SessionExchangeService,
|
||||
AuthCronJob,
|
||||
AuthWebsocketOptionsProvider,
|
||||
],
|
||||
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
|
||||
exports: [
|
||||
AuthService,
|
||||
AuthGuard,
|
||||
JwtSessionService,
|
||||
SessionIssuer,
|
||||
AuthChallengeStore,
|
||||
MagicLinkAuthService,
|
||||
OpenAppAuthService,
|
||||
AuthMethodsService,
|
||||
SessionExchangeService,
|
||||
AuthWebsocketOptionsProvider,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
export { AuthChallengeStore } from './challenge-store';
|
||||
export * from './guard';
|
||||
export * from './identity';
|
||||
export * from './input';
|
||||
export { MagicLinkAuthService } from './magic-link';
|
||||
export * from './methods';
|
||||
export { SessionExchangeService };
|
||||
export { OpenAppAuthService } from './open-app';
|
||||
export { ClientTokenType } from './resolver';
|
||||
export { AuthService };
|
||||
export { AuthService, JwtSessionService, SessionIssuer };
|
||||
export * from './session';
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Request } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getRequestCookie, getRequestHeader } from '../../base';
|
||||
|
||||
export const CLIENT_KIND_HEADER = 'x-affine-client-kind';
|
||||
export const SESSION_COOKIE_NAME = 'affine_session';
|
||||
export const USER_COOKIE_NAME = 'affine_user_id';
|
||||
export const CSRF_COOKIE_NAME = 'affine_csrf_token';
|
||||
|
||||
const NativeClientOriginSchema = z
|
||||
.enum(['capacitor://localhost', 'ionic://localhost', 'https://localhost'])
|
||||
.optional();
|
||||
|
||||
const NativeClientHeadersSchema = z.object({
|
||||
clientKind: z.literal('native'),
|
||||
origin: NativeClientOriginSchema,
|
||||
});
|
||||
|
||||
export const BearerHeaderSchema = z
|
||||
.string()
|
||||
.regex(/^Bearer\s+\S+$/i)
|
||||
.transform(value => value.replace(/^Bearer\s+/i, ''));
|
||||
|
||||
export function extractTokenFromHeader(authorization: string) {
|
||||
const parsed = BearerHeaderSchema.safeParse(authorization);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
export const SessionIdSchema = z.string().uuid();
|
||||
|
||||
export const UserIdSchema = z.union([
|
||||
z.string().uuid(),
|
||||
z.string().regex(/^[A-Za-z0-9_-]{1,128}$/),
|
||||
]);
|
||||
|
||||
export const OAuthCallbackBodySchema = z.object({
|
||||
code: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
client_nonce: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullish()
|
||||
.transform(value => value ?? undefined),
|
||||
});
|
||||
|
||||
export const OAuthPreflightBodySchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullish()
|
||||
.transform(value => value ?? undefined),
|
||||
client: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullish()
|
||||
.transform(value => value ?? undefined),
|
||||
client_nonce: z.string().min(1),
|
||||
});
|
||||
|
||||
export const OAuthStateEnvelopeSchema = z.object({
|
||||
state: z.string().min(1),
|
||||
provider: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export function getSessionOptionsFromRequest(req: Request) {
|
||||
const sessionId = SessionIdSchema.safeParse(
|
||||
getRequestCookie(req, SESSION_COOKIE_NAME)
|
||||
);
|
||||
const userId = UserIdSchema.safeParse(
|
||||
getRequestCookie(req, USER_COOKIE_NAME)
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: sessionId.success ? sessionId.data : undefined,
|
||||
userId: userId.success ? userId.data : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNativeClientRequest(req: Request) {
|
||||
return NativeClientHeadersSchema.safeParse({
|
||||
clientKind: getRequestHeader(req, CLIENT_KIND_HEADER),
|
||||
origin: getRequestHeader(req, 'origin'),
|
||||
}).success;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken';
|
||||
|
||||
import { AuthenticationRequired, CryptoHelper } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { sessionUser } from './service';
|
||||
import type { CurrentUser, Session } from './session';
|
||||
|
||||
const JWT_SESSION_TYPE = 'user_session';
|
||||
const JWT_SESSION_ISSUER = 'affine';
|
||||
const JWT_SESSION_AUDIENCE = 'affine-client';
|
||||
const JWT_SESSION_TTL = 15 * 60;
|
||||
|
||||
export interface SignedJwtSession {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
interface UserSessionJwtPayload extends JwtPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
typ: typeof JWT_SESSION_TYPE;
|
||||
}
|
||||
|
||||
function isUserSessionJwtPayload(
|
||||
payload: string | JwtPayload
|
||||
): payload is UserSessionJwtPayload {
|
||||
return (
|
||||
typeof payload !== 'string' &&
|
||||
typeof payload.sub === 'string' &&
|
||||
typeof payload.sid === 'string' &&
|
||||
payload.typ === JWT_SESSION_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtSessionService {
|
||||
constructor(
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
private get currentKey() {
|
||||
return Buffer.concat([
|
||||
Buffer.from('affine:user-session-jwt:v1:'),
|
||||
this.crypto.keyPair.sha256.privateKey,
|
||||
]);
|
||||
}
|
||||
|
||||
sign(userId: string, sessionId: string): SignedJwtSession {
|
||||
const expiresAt = new Date(Date.now() + JWT_SESSION_TTL * 1000);
|
||||
const token = jwt.sign(
|
||||
{ sid: sessionId, typ: JWT_SESSION_TYPE },
|
||||
this.currentKey,
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: JWT_SESSION_AUDIENCE,
|
||||
expiresIn: JWT_SESSION_TTL,
|
||||
issuer: JWT_SESSION_ISSUER,
|
||||
subject: userId,
|
||||
}
|
||||
);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async verify(token: string): Promise<Session> {
|
||||
let payload: string | JwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, this.currentKey, {
|
||||
algorithms: ['HS256'],
|
||||
audience: JWT_SESSION_AUDIENCE,
|
||||
issuer: JWT_SESSION_ISSUER,
|
||||
});
|
||||
} catch {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
if (!isUserSessionJwtPayload(payload)) throw new AuthenticationRequired();
|
||||
const userSession = await this.models.session
|
||||
.findUserSessionsBySessionId(payload.sid)
|
||||
.then(sessions => sessions.find(s => s.userId === payload.sub));
|
||||
if (!userSession) throw new AuthenticationRequired();
|
||||
const user = await this.models.user.get(payload.sub);
|
||||
if (!user) throw new AuthenticationRequired();
|
||||
return { ...userSession, user: sessionUser(user) as CurrentUser };
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyJwt(token: string) {
|
||||
return token.split('.').length === 3;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
InvalidEmailToken,
|
||||
SignUpForbidden,
|
||||
URLHelper,
|
||||
WrongSignInCredentials,
|
||||
} from '../../base';
|
||||
import { Models, TokenType } from '../../models';
|
||||
import { validators } from '../utils/validators';
|
||||
import { verifyEmailDomainRecords } from './email-domain';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Injectable()
|
||||
export class MagicLinkAuthService {
|
||||
private readonly logger = new Logger(MagicLinkAuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
async send(email: string, callbackUrl = '/magic-link', clientNonce?: string) {
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const callbackUrlObj = this.url.url(callbackUrl);
|
||||
const redirectUriInCallback =
|
||||
callbackUrlObj.searchParams.get('redirect_uri');
|
||||
if (
|
||||
redirectUriInCallback &&
|
||||
!this.url.isAllowedRedirectUri(redirectUriInCallback)
|
||||
) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const user = await this.models.user.getUserByEmail(email, {
|
||||
withDisabled: true,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await this.assertSignupAllowed(email);
|
||||
} else if (user.disabled) {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
const ttlInSec = 30 * 60;
|
||||
const token = await this.models.verificationToken.create(
|
||||
TokenType.SignIn,
|
||||
email,
|
||||
ttlInSec
|
||||
);
|
||||
|
||||
const otp = this.crypto.otp();
|
||||
await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce);
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, { token: otp, email });
|
||||
if (env.dev) {
|
||||
this.logger.debug(`Magic link: ${magicLink}`);
|
||||
}
|
||||
|
||||
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
|
||||
|
||||
return { email };
|
||||
}
|
||||
|
||||
async verify(
|
||||
email: string,
|
||||
otp: string,
|
||||
clientNonce?: string
|
||||
): Promise<VerifiedIdentity> {
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const consumed = await this.models.magicLinkOtp.consume(
|
||||
email,
|
||||
otp,
|
||||
clientNonce
|
||||
);
|
||||
if (!consumed.ok) {
|
||||
if (consumed.reason === 'nonce_mismatch') {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const tokenRecord = await this.models.verificationToken.verify(
|
||||
TokenType.SignIn,
|
||||
consumed.token,
|
||||
{
|
||||
credential: email,
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const user = await this.models.user.fulfill(email);
|
||||
|
||||
return { userId: user.id, method: 'magic_link' };
|
||||
}
|
||||
|
||||
private async assertSignupAllowed(email: string) {
|
||||
if (!this.config.auth.allowSignup) {
|
||||
throw new SignUpForbidden();
|
||||
}
|
||||
|
||||
if (!this.config.auth.requireEmailDomainVerification) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await verifyEmailDomainRecords(email))) {
|
||||
throw new InvalidEmail({ email });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import { Models, type User } from '../../models';
|
||||
import { verifyEmailDomainRecords } from './email-domain';
|
||||
|
||||
export const AUTH_OAUTH_PROVIDER_READER = Symbol('AUTH_OAUTH_PROVIDER_READER');
|
||||
|
||||
interface OAuthProviderReader {
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
export interface LoginAuthMethods {
|
||||
password: { available: boolean };
|
||||
magicLink: { available: boolean };
|
||||
oauth: { available: boolean; providers: string[] };
|
||||
passkey: { available: boolean; discoverable: boolean };
|
||||
}
|
||||
|
||||
export interface BoundAuthMethods {
|
||||
password: { bound: boolean };
|
||||
oauth: { bound: boolean; providers: string[] };
|
||||
passkey: { bound: boolean; count: number };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMethodsService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly ref: ModuleRef
|
||||
) {}
|
||||
|
||||
async loginPreflight(email: string) {
|
||||
const [user, userWithDisabled] = await Promise.all([
|
||||
this.models.user.getUserByEmail(email),
|
||||
this.models.user.getUserByEmail(email, {
|
||||
withDisabled: true,
|
||||
}),
|
||||
]);
|
||||
const disabledUser =
|
||||
userWithDisabled?.disabled && !user ? userWithDisabled : null;
|
||||
const providers = this.oauthProviders();
|
||||
|
||||
return {
|
||||
registered: !!user?.registered,
|
||||
methods: {
|
||||
password: {
|
||||
available:
|
||||
!!user?.password &&
|
||||
!user.disabled &&
|
||||
(await this.canPasswordSignIn(email)),
|
||||
},
|
||||
magicLink: {
|
||||
available: await this.canMagicLinkSignIn(email, user, disabledUser),
|
||||
},
|
||||
oauth: {
|
||||
available: providers.length > 0,
|
||||
providers,
|
||||
},
|
||||
passkey: {
|
||||
available: false,
|
||||
discoverable: false,
|
||||
},
|
||||
} satisfies LoginAuthMethods,
|
||||
};
|
||||
}
|
||||
|
||||
async boundMethods(userId: string): Promise<BoundAuthMethods> {
|
||||
const [user, connectedAccounts] = await Promise.all([
|
||||
this.models.user.get(userId),
|
||||
this.db.connectedAccount.findMany({
|
||||
select: { provider: true },
|
||||
where: { userId },
|
||||
}),
|
||||
]);
|
||||
const providers = Array.from(
|
||||
new Set(connectedAccounts.map(account => account.provider))
|
||||
);
|
||||
|
||||
return {
|
||||
password: { bound: !!user?.password },
|
||||
oauth: { bound: providers.length > 0, providers },
|
||||
passkey: { bound: false, count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
private async canPasswordSignIn(_email: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async canMagicLinkSignIn(
|
||||
email: string,
|
||||
user: User | null,
|
||||
disabledUser: User | null
|
||||
) {
|
||||
if (disabledUser) {
|
||||
return false;
|
||||
}
|
||||
if (user) {
|
||||
return !user.disabled;
|
||||
}
|
||||
if (!this.config.auth.allowSignup) {
|
||||
return false;
|
||||
}
|
||||
return this.emailDomainAllowed(email);
|
||||
}
|
||||
|
||||
private async emailDomainAllowed(email: string) {
|
||||
if (!this.config.auth.requireEmailDomainVerification) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return verifyEmailDomainRecords(email);
|
||||
}
|
||||
|
||||
private oauthProviders() {
|
||||
try {
|
||||
const reader = this.ref.get<OAuthProviderReader>(
|
||||
AUTH_OAUTH_PROVIDER_READER,
|
||||
{ strict: false }
|
||||
);
|
||||
return reader.providers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ActionForbidden, InvalidAuthState } from '../../base';
|
||||
import { AuthChallengeStore } from './challenge-store';
|
||||
import { isNativeClientRequest } from './input';
|
||||
import { JwtSessionService } from './jwt-session';
|
||||
import { AuthService } from './service';
|
||||
|
||||
interface SessionExchangePayload {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionExchangeService {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly challenges: AuthChallengeStore,
|
||||
private readonly jwtSession: JwtSessionService
|
||||
) {}
|
||||
|
||||
async createCode(req: Request, userId: string, sessionId: string) {
|
||||
if (!isNativeClientRequest(req)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.challenges.create<SessionExchangePayload>(
|
||||
'native_session_exchange',
|
||||
{ userId, sessionId },
|
||||
60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
async exchange(req: Request, code: string) {
|
||||
if (!isNativeClientRequest(req)) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const payload = await this.challenges.consume<SessionExchangePayload>(
|
||||
'native_session_exchange',
|
||||
code
|
||||
);
|
||||
|
||||
if (!payload?.userId || !payload.sessionId) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
const session = await this.auth.getUserSession(
|
||||
payload.sessionId,
|
||||
payload.userId
|
||||
);
|
||||
if (!session) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
return this.jwtSession.sign(payload.userId, payload.sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { InvalidAuthState } from '../../base';
|
||||
import { AuthChallengeStore } from './challenge-store';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import type { CurrentUser } from './session';
|
||||
|
||||
@Injectable()
|
||||
export class OpenAppAuthService {
|
||||
constructor(private readonly challenges: AuthChallengeStore) {}
|
||||
|
||||
async createSignInCode(user: CurrentUser) {
|
||||
return this.challenges.create(
|
||||
'open_app_sign_in',
|
||||
{ userId: user.id },
|
||||
5 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
async verifySignInCode(code: string): Promise<VerifiedIdentity> {
|
||||
const payload = await this.challenges.consume<{ userId?: string }>(
|
||||
'open_app_sign_in',
|
||||
code
|
||||
);
|
||||
|
||||
if (!payload?.userId) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
return { userId: payload.userId, method: 'open_app' };
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class AuthResolver {
|
||||
|
||||
@ResolveField(() => ClientTokenType, {
|
||||
name: 'token',
|
||||
deprecationReason: 'use [/api/auth/sign-in?native=true] instead',
|
||||
deprecationReason: 'use native session exchange instead',
|
||||
})
|
||||
async clientToken(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
|
||||
@@ -4,14 +4,18 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import {
|
||||
Config,
|
||||
getClientVersionFromRequest,
|
||||
SignUpForbidden,
|
||||
} from '../../base';
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
import { createDevUsers } from './dev';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import {
|
||||
CSRF_COOKIE_NAME,
|
||||
extractTokenFromHeader,
|
||||
getSessionOptionsFromRequest,
|
||||
SESSION_COOKIE_NAME,
|
||||
USER_COOKIE_NAME,
|
||||
} from './input';
|
||||
import type { CurrentUser } from './session';
|
||||
|
||||
export function sessionUser(
|
||||
@@ -27,20 +31,12 @@ export function sessionUser(
|
||||
});
|
||||
}
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
if (!/^Bearer\s/i.test(authorization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authorization.substring(7);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnApplicationBootstrap {
|
||||
readonly cookieOptions: CookieOptions;
|
||||
static readonly sessionCookieName = 'affine_session';
|
||||
static readonly userCookieName = 'affine_user_id';
|
||||
static readonly csrfCookieName = 'affine_csrf_token';
|
||||
static readonly sessionCookieName = SESSION_COOKIE_NAME;
|
||||
static readonly userCookieName = USER_COOKIE_NAME;
|
||||
static readonly csrfCookieName = CSRF_COOKIE_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
@@ -90,6 +86,14 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return this.models.user.signIn(email, password).then(sessionUser);
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<VerifiedIdentity> {
|
||||
const user = await this.models.user.signIn(email, password);
|
||||
return { userId: user.id, method: 'password' };
|
||||
}
|
||||
|
||||
async signOut(sessionId: string, userId?: string) {
|
||||
// sign out all users in the session
|
||||
if (!userId) {
|
||||
@@ -104,10 +108,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
userId?: string
|
||||
): Promise<{ user: CurrentUser; session: UserSession } | null> {
|
||||
const sessions = await this.getUserSessions(sessionId);
|
||||
|
||||
if (!sessions.length) {
|
||||
return null;
|
||||
}
|
||||
if (!sessions.length) return null;
|
||||
|
||||
let userSession: UserSession | undefined;
|
||||
|
||||
@@ -201,55 +202,6 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return await this.models.session.deleteUserSessions(userId);
|
||||
}
|
||||
|
||||
getSessionOptionsFromRequest(req: Request) {
|
||||
let sessionId: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionId && req.headers.authorization) {
|
||||
sessionId = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
const userId: string | undefined =
|
||||
req.cookies[AuthService.userCookieName] ||
|
||||
req.headers[AuthService.userCookieName.replaceAll('_', '-')];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
async setCookies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
userId: string,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const { sessionId } = this.getSessionOptionsFromRequest(req);
|
||||
|
||||
const signInClientVersion =
|
||||
clientVersion ?? getClientVersionFromRequest(req);
|
||||
const userSession = await this.createUserSession(
|
||||
userId,
|
||||
sessionId,
|
||||
undefined,
|
||||
signInClientVersion
|
||||
);
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
|
||||
...this.cookieOptions,
|
||||
expires: userSession.expiresAt ?? void 0,
|
||||
});
|
||||
|
||||
res.cookie(AuthService.csrfCookieName, randomUUID(), {
|
||||
...this.cookieOptions,
|
||||
httpOnly: false,
|
||||
expires: userSession.expiresAt ?? void 0,
|
||||
});
|
||||
|
||||
this.setUserCookie(res, userId);
|
||||
}
|
||||
|
||||
async refreshCookies(res: Response, sessionId?: string) {
|
||||
if (sessionId) {
|
||||
const users = await this.getUserList(sessionId);
|
||||
@@ -264,7 +216,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
this.clearCookies(res);
|
||||
}
|
||||
|
||||
private clearCookies(res: Response<any, Record<string, any>>) {
|
||||
clearCookies(res: Response<any, Record<string, any>>) {
|
||||
res.clearCookie(AuthService.sessionCookieName);
|
||||
res.clearCookie(AuthService.userCookieName);
|
||||
res.clearCookie(AuthService.csrfCookieName);
|
||||
@@ -281,12 +233,8 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async getUserSessionFromRequest(req: Request, res?: Response) {
|
||||
const { sessionId, userId } = this.getSessionOptionsFromRequest(req);
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sessionId, userId } = getSessionOptionsFromRequest(req);
|
||||
if (!sessionId) return null;
|
||||
const session = await this.getUserSession(sessionId, userId);
|
||||
|
||||
if (res) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { getClientVersionFromRequest, getRequestCookie } from '../../base';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import { isNativeClientRequest } from './input';
|
||||
import { SessionExchangeService } from './native-exchange';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export type IssuedSession = {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
exchangeCode?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SessionIssuer {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly sessionExchange: SessionExchangeService
|
||||
) {}
|
||||
|
||||
async issue(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity
|
||||
): Promise<IssuedSession> {
|
||||
const nativeClient = isNativeClientRequest(req);
|
||||
const sessionId =
|
||||
req.authType === 'jwt'
|
||||
? req.session?.sessionId
|
||||
: getRequestCookie(req, AuthService.sessionCookieName);
|
||||
const signInClientVersion =
|
||||
identity.clientVersion ?? getClientVersionFromRequest(req);
|
||||
const userSession = await this.auth.createUserSession(
|
||||
identity.userId,
|
||||
sessionId,
|
||||
undefined,
|
||||
signInClientVersion
|
||||
);
|
||||
|
||||
if (nativeClient) {
|
||||
this.auth.clearCookies(res);
|
||||
} else {
|
||||
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
|
||||
...this.auth.cookieOptions,
|
||||
expires: userSession.expiresAt ?? void 0,
|
||||
});
|
||||
|
||||
res.cookie(AuthService.csrfCookieName, randomUUID(), {
|
||||
...this.auth.cookieOptions,
|
||||
httpOnly: false,
|
||||
expires: userSession.expiresAt ?? void 0,
|
||||
});
|
||||
|
||||
this.auth.setUserCookie(res, identity.userId);
|
||||
}
|
||||
|
||||
const exchangeCode = await this.sessionExchange.createCode(
|
||||
req,
|
||||
identity.userId,
|
||||
userSession.sessionId
|
||||
);
|
||||
|
||||
return {
|
||||
userId: identity.userId,
|
||||
sessionId: userSession.sessionId,
|
||||
exchangeCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UseNamedGuard,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { AuthService, Public } from '../auth';
|
||||
import { Public, SessionIssuer } from '../auth';
|
||||
import { ServerService } from '../config';
|
||||
import { validators } from '../utils/validators';
|
||||
|
||||
@@ -26,7 +26,7 @@ export class CustomSetupController {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly auth: AuthService,
|
||||
private readonly sessionIssuer: SessionIssuer,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly server: ServerService
|
||||
) {}
|
||||
@@ -72,7 +72,10 @@ export class CustomSetupController {
|
||||
'selfhost setup'
|
||||
);
|
||||
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
await this.sessionIssuer.issue(req, res, {
|
||||
userId: user.id,
|
||||
method: 'password',
|
||||
});
|
||||
res.send({ id: user.id, email: user.email, name: user.name });
|
||||
} catch (e) {
|
||||
await this.models.user.delete(user.id);
|
||||
|
||||
Reference in New Issue
Block a user