diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 1207337240..37c881270a 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -244,6 +244,31 @@ "type": "number", "description": "Application auth time to refresh in seconds.\n@default 604800", "default": 604800 + }, + "token.accessTokenTtl": { + "type": "number", + "description": "Access JWT expiration time in seconds.\n@default 900", + "default": 900 + }, + "token.refreshIdleTtl": { + "type": "number", + "description": "Auth refresh session inactivity expiration in seconds.\n@default 2592000", + "default": 2592000 + }, + "token.refreshAbsoluteTtl": { + "type": "number", + "description": "Auth refresh session absolute expiration in seconds.\n@default 15552000", + "default": 15552000 + }, + "token.refreshGracePeriod": { + "type": "number", + "description": "One-use refresh rotation concurrency grace period in seconds.\n@default 30", + "default": 30 + }, + "token.refreshRetention": { + "type": "number", + "description": "Retention for expired auth refresh generations in seconds.\n@default 2592000", + "default": 2592000 } } }, @@ -1111,10 +1136,12 @@ }, "config": { "type": "object", - "description": "The config for the captcha plugin.\n@default {\"turnstile\":{\"secret\":\"\"},\"challenge\":{\"bits\":20}}", + "description": "The config for the captcha plugin.\n@default {\"turnstile\":{\"secret\":\"\",\"siteKey\":\"\",\"action\":\"auth-sign-in\"},\"challenge\":{\"bits\":20}}", "default": { "turnstile": { - "secret": "" + "secret": "", + "siteKey": "", + "action": "auth-sign-in" }, "challenge": { "bits": 20 diff --git a/Cargo.lock b/Cargo.lock index 544a0e9b85..16c650ee6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,7 @@ dependencies = [ "doc_extractor", "file-format", "hex", + "hmac 0.13.0", "homedir", "image", "infer", diff --git a/packages/backend/native/Cargo.toml b/packages/backend/native/Cargo.toml index cee5a1556e..191e07034b 100644 --- a/packages/backend/native/Cargo.toml +++ b/packages/backend/native/Cargo.toml @@ -21,6 +21,7 @@ crc32fast = "1.5.0" doc_extractor = { workspace = true } file-format = { workspace = true } hex = { workspace = true } +hmac = "0.13" homedir = { workspace = true } image = { workspace = true } infer = { workspace = true } diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index ea33ccd869..2a7c2207c6 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -171,6 +171,20 @@ export interface AssertSafeUrlRequest { url: string } +export declare function authSessionAccessTokenKeyId(token: string): string | null + +export interface AuthSessionAccessTokenVerification { + status: string + userId?: string + authSessionId?: string +} + +export interface AuthSessionRefreshToken { + token: string + id: string + secretHash: string +} + export interface BackendRuntimeHealth { started: boolean databaseConnected: boolean @@ -299,6 +313,8 @@ export interface CoordinationLeaseGrant { fencingToken: bigint | number } +export declare function createAuthSessionRefreshToken(): AuthSessionRefreshToken + /** * Converts markdown content to AFFiNE-compatible y-octo document binary. * @@ -646,6 +662,13 @@ export interface NativeWorkspaceDocContent { avatarKey: string } +export declare function parseAuthSessionRefreshToken(token: string): ParsedAuthSessionRefreshToken | null + +export interface ParsedAuthSessionRefreshToken { + id: string + secretHash: string +} + export interface ParsedDoc { name: string chunks: Array @@ -1161,6 +1184,8 @@ export interface SafeFetchResponse { export declare function scanContentPolicyV1(input: ContentPolicyScanInput): ContentPolicyScanResult +export declare function signAuthSessionAccessToken(userId: string, authSessionId: string, keyId: string, secret: Buffer, issuedAt: number, expiresAt: number): string + export interface StorageProviderCapabilities { put: boolean get: boolean @@ -1255,4 +1280,6 @@ export declare function updateRootDocMetaTitle(rootDocBin: Buffer, docId: string */ export declare function validateDocUpdate(update: Buffer): Promise +export declare function verifyAuthSessionAccessToken(token: string, expectedKeyId: string, secret: Buffer, now: number): AuthSessionAccessTokenVerification + export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise diff --git a/packages/backend/native/src/auth_session.rs b/packages/backend/native/src/auth_session.rs new file mode 100644 index 0000000000..c58d991074 --- /dev/null +++ b/packages/backend/native/src/auth_session.rs @@ -0,0 +1,278 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use napi::{Result, bindgen_prelude::*}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const ACCESS_TOKEN_TYPE: &str = "session_access"; +const ACCESS_TOKEN_ISSUER: &str = "affine"; +const ACCESS_TOKEN_AUDIENCE: &str = "affine-client"; +const REFRESH_TOKEN_PREFIX: &str = "aff_rt_v1"; +const CLOCK_TOLERANCE_SECONDS: i64 = 30; + +type HmacSha256 = Hmac; + +#[derive(Serialize)] +struct AccessTokenHeader<'a> { + alg: &'static str, + typ: &'static str, + kid: &'a str, +} + +#[derive(Deserialize)] +struct ParsedAccessTokenHeader { + alg: String, + typ: String, + kid: String, +} + +#[derive(Serialize, Deserialize)] +struct AccessTokenClaims<'a> { + sub: &'a str, + sid: &'a str, + typ: &'static str, + iss: &'static str, + aud: &'static str, + iat: i64, + exp: i64, +} + +#[derive(Deserialize)] +struct ParsedAccessTokenClaims { + sub: String, + sid: String, + typ: String, + iss: String, + aud: String, + iat: i64, + exp: i64, +} + +#[napi(object)] +pub struct AuthSessionAccessTokenVerification { + pub status: String, + pub user_id: Option, + pub auth_session_id: Option, +} + +#[napi(object)] +pub struct AuthSessionRefreshToken { + pub token: String, + pub id: String, + pub secret_hash: String, +} + +#[napi(object)] +pub struct ParsedAuthSessionRefreshToken { + pub id: String, + pub secret_hash: String, +} + +fn invalid_access_token() -> AuthSessionAccessTokenVerification { + AuthSessionAccessTokenVerification { + status: "invalid".into(), + user_id: None, + auth_session_id: None, + } +} + +fn decode_segment Deserialize<'de>>(segment: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(segment).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn hash_refresh_secret(secret: &[u8]) -> String { + hex::encode(Sha256::digest(secret)) +} + +#[napi] +pub fn auth_session_access_token_key_id(token: String) -> Option { + let mut segments = token.split('.'); + let header: ParsedAccessTokenHeader = decode_segment(segments.next()?)?; + segments.next()?; + segments.next()?; + if segments.next().is_some() || header.alg != "HS256" || header.typ != "JWT" { + return None; + } + Some(header.kid) +} + +#[napi] +pub fn sign_auth_session_access_token( + user_id: String, + auth_session_id: String, + key_id: String, + secret: Buffer, + issued_at: i64, + expires_at: i64, +) -> Result { + if user_id.is_empty() || auth_session_id.is_empty() || key_id.is_empty() { + return Err(Error::from_reason("Access token identifiers must not be empty")); + } + if secret.len() < 32 { + return Err(Error::from_reason("Access token signing key is too short")); + } + if expires_at <= issued_at { + return Err(Error::from_reason("Access token expiry must follow issuance")); + } + let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&AccessTokenHeader { + alg: "HS256", + typ: "JWT", + kid: &key_id, + })?); + let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&AccessTokenClaims { + sub: &user_id, + sid: &auth_session_id, + typ: ACCESS_TOKEN_TYPE, + iss: ACCESS_TOKEN_ISSUER, + aud: ACCESS_TOKEN_AUDIENCE, + iat: issued_at, + exp: expires_at, + })?); + let signing_input = format!("{header}.{claims}"); + let mut mac = + HmacSha256::new_from_slice(secret.as_ref()).map_err(|_| Error::from_reason("Invalid access token signing key"))?; + mac.update(signing_input.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + Ok(format!("{signing_input}.{signature}")) +} + +#[napi] +pub fn verify_auth_session_access_token( + token: String, + expected_key_id: String, + secret: Buffer, + now: i64, +) -> AuthSessionAccessTokenVerification { + let segments = token.split('.').collect::>(); + if segments.len() != 3 { + return invalid_access_token(); + } + let Some(header) = decode_segment::(segments[0]) else { + return invalid_access_token(); + }; + if header.alg != "HS256" || header.typ != "JWT" || header.kid != expected_key_id { + return invalid_access_token(); + } + let Ok(signature) = URL_SAFE_NO_PAD.decode(segments[2]) else { + return invalid_access_token(); + }; + let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_ref()) else { + return invalid_access_token(); + }; + mac.update(format!("{}.{}", segments[0], segments[1]).as_bytes()); + if mac.verify_slice(&signature).is_err() { + return invalid_access_token(); + } + let Some(claims) = decode_segment::(segments[1]) else { + return invalid_access_token(); + }; + if claims.typ != ACCESS_TOKEN_TYPE + || claims.iss != ACCESS_TOKEN_ISSUER + || claims.aud != ACCESS_TOKEN_AUDIENCE + || claims.iat > now + CLOCK_TOLERANCE_SECONDS + { + return invalid_access_token(); + } + if claims.exp + CLOCK_TOLERANCE_SECONDS <= now { + return AuthSessionAccessTokenVerification { + status: "expired".into(), + user_id: None, + auth_session_id: None, + }; + } + if claims.exp <= claims.iat { + return invalid_access_token(); + } + AuthSessionAccessTokenVerification { + status: "valid".into(), + user_id: Some(claims.sub), + auth_session_id: Some(claims.sid), + } +} + +#[napi] +pub fn create_auth_session_refresh_token() -> AuthSessionRefreshToken { + let mut id = [0_u8; 18]; + let mut secret = [0_u8; 32]; + rand::rng().fill_bytes(&mut id); + rand::rng().fill_bytes(&mut secret); + let id = URL_SAFE_NO_PAD.encode(id); + let encoded_secret = URL_SAFE_NO_PAD.encode(secret); + AuthSessionRefreshToken { + token: format!("{REFRESH_TOKEN_PREFIX}.{id}.{encoded_secret}"), + id, + secret_hash: hash_refresh_secret(&secret), + } +} + +#[napi] +pub fn parse_auth_session_refresh_token(token: String) -> Option { + let segments = token.split('.').collect::>(); + if segments.len() != 3 || segments[0] != REFRESH_TOKEN_PREFIX { + return None; + } + let id = URL_SAFE_NO_PAD.decode(segments[1]).ok()?; + let secret = URL_SAFE_NO_PAD.decode(segments[2]).ok()?; + if id.len() != 18 || secret.len() != 32 { + return None; + } + Some(ParsedAuthSessionRefreshToken { + id: segments[1].into(), + secret_hash: hash_refresh_secret(&secret), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_token_vector_and_policy() { + let secret = || Buffer::from(vec![7; 32]); + let token = sign_auth_session_access_token( + "user-1".into(), + "session-1".into(), + "key-1".into(), + secret(), + 1_700_000_000, + 1_700_000_900, + ) + .unwrap(); + assert_eq!(auth_session_access_token_key_id(token.clone()), Some("key-1".into())); + let valid = verify_auth_session_access_token(token.clone(), "key-1".into(), secret(), 1_700_000_100); + assert_eq!(valid.status, "valid"); + assert_eq!(valid.user_id.as_deref(), Some("user-1")); + assert_eq!( + verify_auth_session_access_token(token.clone(), "key-1".into(), secret(), 1_700_000_929).status, + "valid" + ); + assert_eq!( + verify_auth_session_access_token(token.clone(), "key-1".into(), secret(), 1_700_000_930).status, + "expired" + ); + assert_eq!( + verify_auth_session_access_token(token, "key-1".into(), secret(), 1_700_001_000).status, + "expired" + ); + } + + #[test] + fn refresh_token_round_trip_and_rejection() { + let created = create_auth_session_refresh_token(); + let parsed = parse_auth_session_refresh_token(created.token).unwrap(); + assert_eq!(parsed.id, created.id); + assert_eq!(parsed.secret_hash, created.secret_hash); + assert!(parse_auth_session_refresh_token("aff_rt_v1.bad.bad".into()).is_none()); + let golden = parse_auth_session_refresh_token( + "aff_rt_v1.AAECAwQFBgcICQoLDA0ODxAR.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".into(), + ) + .unwrap(); + assert_eq!(golden.id, "AAECAwQFBgcICQoLDA0ODxAR"); + assert_eq!( + golden.secret_hash, + "630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd" + ); + } +} diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index 6005e49c97..5f8a504bb5 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -2,6 +2,7 @@ mod utils; +pub mod auth_session; pub mod content_policy; pub mod doc; pub mod doc_loader; diff --git a/packages/backend/server/migrations/20260711080000_auth_sessions/migration.sql b/packages/backend/server/migrations/20260711080000_auth_sessions/migration.sql new file mode 100644 index 0000000000..0eed4fc38b --- /dev/null +++ b/packages/backend/server/migrations/20260711080000_auth_sessions/migration.sql @@ -0,0 +1,44 @@ +CREATE TABLE "auth_sessions" ( + "id" VARCHAR NOT NULL, + "user_session_id" VARCHAR NOT NULL, + "installation_id" VARCHAR NOT NULL, + "platform" VARCHAR NOT NULL, + "device_name" VARCHAR, + "app_version" VARCHAR, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "idle_expires_at" TIMESTAMPTZ(3) NOT NULL, + "absolute_expires_at" TIMESTAMPTZ(3) NOT NULL, + "revoked_at" TIMESTAMPTZ(3), + "revoke_reason" VARCHAR, + "last_ip_hash" VARCHAR, + "last_user_agent" VARCHAR, + CONSTRAINT "auth_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "auth_refresh_tokens" ( + "id" VARCHAR NOT NULL, + "auth_session_id" VARCHAR NOT NULL, + "generation" INTEGER NOT NULL, + "secret_hash" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "used_at" TIMESTAMPTZ(3), + "replaced_by_id" VARCHAR, + "grace_used_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + CONSTRAINT "auth_refresh_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "auth_sessions_user_session_id_key" ON "auth_sessions"("user_session_id"); +CREATE INDEX "auth_sessions_installation_id_idx" ON "auth_sessions"("installation_id"); +CREATE INDEX "auth_sessions_idle_expires_at_idx" ON "auth_sessions"("idle_expires_at"); +CREATE INDEX "auth_sessions_absolute_expires_at_idx" ON "auth_sessions"("absolute_expires_at"); +CREATE UNIQUE INDEX "auth_refresh_tokens_replaced_by_id_key" ON "auth_refresh_tokens"("replaced_by_id"); +CREATE UNIQUE INDEX "auth_refresh_tokens_auth_session_id_generation_key" ON "auth_refresh_tokens"("auth_session_id", "generation"); +CREATE INDEX "auth_refresh_tokens_auth_session_id_used_at_idx" ON "auth_refresh_tokens"("auth_session_id", "used_at"); +CREATE INDEX "auth_refresh_tokens_expires_at_idx" ON "auth_refresh_tokens"("expires_at"); + +ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_session_id_fkey" FOREIGN KEY ("user_session_id") REFERENCES "user_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "auth_refresh_tokens" ADD CONSTRAINT "auth_refresh_tokens_auth_session_id_fkey" FOREIGN KEY ("auth_session_id") REFERENCES "auth_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "auth_refresh_tokens" ADD CONSTRAINT "auth_refresh_tokens_replaced_by_id_fkey" FOREIGN KEY ("replaced_by_id") REFERENCES "auth_refresh_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 4d0c59eecd..479dfc65ed 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -97,13 +97,61 @@ model UserSession { refreshClientVersion String? @map("refresh_client_version") @db.VarChar createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + authSession AuthSession? @@unique([sessionId, userId]) @@map("user_sessions") } +model AuthSession { + id String @id @default(uuid()) @db.VarChar + userSessionId String @unique @map("user_session_id") @db.VarChar + installationId String @map("installation_id") @db.VarChar + platform String @db.VarChar + deviceName String? @map("device_name") @db.VarChar + appVersion String? @map("app_version") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(3) + idleExpiresAt DateTime @map("idle_expires_at") @db.Timestamptz(3) + absoluteExpiresAt DateTime @map("absolute_expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + revokeReason String? @map("revoke_reason") @db.VarChar + lastIpHash String? @map("last_ip_hash") @db.VarChar + lastUserAgent String? @map("last_user_agent") @db.VarChar + + userSession UserSession @relation(fields: [userSessionId], references: [id], onDelete: Cascade) + refreshTokens AuthRefreshToken[] + + @@index([installationId]) + @@index([idleExpiresAt]) + @@index([absoluteExpiresAt]) + @@map("auth_sessions") +} + +model AuthRefreshToken { + id String @id @db.VarChar + authSessionId String @map("auth_session_id") @db.VarChar + generation Int + secretHash String @map("secret_hash") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + usedAt DateTime? @map("used_at") @db.Timestamptz(3) + replacedById String? @unique @map("replaced_by_id") @db.VarChar + graceUsedAt DateTime? @map("grace_used_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + + authSession AuthSession @relation(fields: [authSessionId], references: [id], onDelete: Cascade) + replacedBy AuthRefreshToken? @relation("AuthRefreshRotation", fields: [replacedById], references: [id], onDelete: SetNull) + replaces AuthRefreshToken? @relation("AuthRefreshRotation") + + @@unique([authSessionId, generation]) + @@index([authSessionId, usedAt]) + @@index([expiresAt]) + @@map("auth_refresh_tokens") +} + model VerificationToken { token String @db.VarChar type Int @db.SmallInt diff --git a/packages/backend/server/src/__tests__/auth/access-token.spec.ts b/packages/backend/server/src/__tests__/auth/access-token.spec.ts new file mode 100644 index 0000000000..f433b46224 --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/access-token.spec.ts @@ -0,0 +1,364 @@ +import { PrismaClient } from '@prisma/client'; +import ava, { ExecutionContext, TestFn } from 'ava'; +import jwt from 'jsonwebtoken'; + +import { ConfigFactory } from '../../base'; +import { + AccessTokenService, + AuthModule, + AuthService, + AuthSessionService, + AuthSigningKeyRing, + type CurrentUser, +} from '../../core/auth'; +import { Models } from '../../models'; +import { createTestingApp, TestingApp } from '../utils'; + +const test = ava as TestFn<{ + app: TestingApp; + auth: AuthService; + accessTokens: AccessTokenService; + keys: AuthSigningKeyRing; + authSessions: AuthSessionService; + config: ConfigFactory; + models: Models; + db: PrismaClient; + user: CurrentUser; + sessionId: string; + userSessionId: string; + authSessionId: string; +}>; + +test.before(async t => { + const app = await createTestingApp({ + imports: [AuthModule], + }); + + t.context.app = app; + t.context.auth = app.get(AuthService); + t.context.accessTokens = app.get(AccessTokenService); + t.context.keys = app.get(AuthSigningKeyRing); + t.context.authSessions = app.get(AuthSessionService); + t.context.config = app.get(ConfigFactory); + t.context.models = app.get(Models); + t.context.db = app.get(PrismaClient); +}); + +test.beforeEach(async t => { + await t.context.app.initTestingDB(); + resetAuthSessionConfig(t.context.config); + + t.context.user = await t.context.auth.signUp('u1@affine.pro', '1'); + const session = await t.context.auth.createUserSession(t.context.user.id); + t.context.sessionId = session.sessionId; + t.context.userSessionId = session.id; + const authSession = await t.context.authSessions.create({ + userSessionId: session.id, + installationId: 'installation-1', + platform: 'ios', + }); + t.context.authSessionId = authSession.session.id; +}); + +test.after.always(async t => { + await t.context.app.close(); +}); + +const DEFAULT_ACCESS_TOKEN_TTL = 15 * 60; + +function resetAuthSessionConfig(config: ConfigFactory) { + config.override({ + auth: { + session: { + ttl: 60 * 60 * 24 * 15, + ttr: 60 * 60 * 24 * 7, + }, + token: { + accessTokenTtl: DEFAULT_ACCESS_TOKEN_TTL, + }, + }, + }); +} + +function assertSignedTtl( + t: ExecutionContext, + signed: { token: string; expiresAt: Date }, + expectedTtl: number +) { + const ttlMs = signed.expiresAt.getTime() - Date.now(); + t.true(ttlMs > (expectedTtl - 5) * 1000); + t.true(ttlMs <= (expectedTtl + 1) * 1000); + + const payload = jwt.decode(signed.token); + t.truthy(payload); + t.true(typeof payload !== 'string'); + if (!payload || typeof payload === 'string') return; + t.is(typeof payload.iat, 'number'); + t.is(typeof payload.exp, 'number'); + if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number') { + return; + } + t.is(payload.exp - payload.iat, expectedTtl); +} + +test.serial('should sign and verify a auth-session access jwt', async t => { + const signed = await t.context.accessTokens.sign( + t.context.user.id, + t.context.authSessionId + ); + const key = await t.context.keys.active(); + const nativePayload = jwt.verify(signed.token, key.secret, { + algorithms: ['HS256'], + audience: 'affine-client', + issuer: 'affine', + }); + + const session = await t.context.accessTokens.verify(signed.token); + + t.true(typeof nativePayload !== 'string'); + t.is(session.user.id, t.context.user.id); + t.is(session.sessionId, t.context.sessionId); + t.is(session.authSessionId, t.context.authSessionId); + t.true(signed.expiresAt.getTime() > Date.now()); +}); + +test.serial('should use the auth-session access-token ttl', async t => { + const defaultSigned = await t.context.accessTokens.sign( + t.context.user.id, + t.context.authSessionId + ); + assertSignedTtl(t, defaultSigned, DEFAULT_ACCESS_TOKEN_TTL); + + const ttl = 120; + t.context.config.override({ + auth: { + token: { + accessTokenTtl: ttl, + }, + }, + }); + + const configuredSigned = await t.context.accessTokens.sign( + t.context.user.id, + t.context.authSessionId + ); + assertSignedTtl(t, configuredSigned, ttl); +}); + +test.serial('should reject invalid jwt cases', async t => { + const key = await t.context.keys.active(); + const sign = (claims: object, overrides: jwt.SignOptions = {}) => + jwt.sign(claims, key.secret, { + algorithm: 'HS256', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + keyid: key.id, + subject: t.context.user.id, + ...overrides, + }); + const cases: Array<{ name: string; token: string; code: string }> = [ + { + name: 'expired token', + token: sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + { expiresIn: -31 } + ), + code: 'ACCESS_TOKEN_EXPIRED', + }, + { + name: 'wrong signature', + token: jwt.sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + 'wrong-key', + { + algorithm: 'HS256', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + keyid: key.id, + subject: t.context.user.id, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'unknown key id', + token: jwt.sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + key.secret, + { + algorithm: 'HS256', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + keyid: 'unknown', + subject: t.context.user.id, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'wrong algorithm', + token: jwt.sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + key.secret, + { + algorithm: 'HS384', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + keyid: key.id, + subject: t.context.user.id, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'wrong issuer', + token: sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + { issuer: 'other-issuer' } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'wrong audience', + token: sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + { audience: 'other-audience' } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'wrong type', + token: sign({ + sid: t.context.authSessionId, + typ: 'user_session', + }), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'missing time claims', + token: jwt.sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + key.secret, + { + algorithm: 'HS256', + audience: 'affine-client', + issuer: 'affine', + keyid: key.id, + subject: t.context.user.id, + noTimestamp: true, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'wrong header type', + token: jwt.sign( + { sid: t.context.authSessionId, typ: 'session_access' }, + key.secret, + { + algorithm: 'HS256', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + header: { alg: 'HS256', kid: key.id, typ: 'NOT_JWT' }, + subject: t.context.user.id, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + { + name: 'future issued-at', + token: jwt.sign( + { + sid: t.context.authSessionId, + typ: 'session_access', + iat: Math.floor(Date.now() / 1000) + 31, + }, + key.secret, + { + algorithm: 'HS256', + audience: 'affine-client', + expiresIn: 60, + issuer: 'affine', + keyid: key.id, + subject: t.context.user.id, + } + ), + code: 'ACCESS_TOKEN_INVALID', + }, + ]; + + for (const testCase of cases) { + const error = await t.throwsAsync(() => + t.context.accessTokens.verify(testCase.token) + ); + t.is(error?.message, testCase.code, testCase.name); + } +}); + +test.serial( + 'should reject jwt when its auth or user session is invalid', + async t => { + const signed = await t.context.accessTokens.sign( + t.context.user.id, + t.context.authSessionId + ); + + await t.context.authSessions.revoke(t.context.authSessionId, 'test'); + + const revoked = await t.throwsAsync(() => + t.context.accessTokens.verify(signed.token) + ); + t.is(revoked?.message, 'AUTH_SESSION_REVOKED'); + + const refreshed = await t.context.auth.createUserSession(t.context.user.id); + const authSession = await t.context.authSessions.create({ + userSessionId: refreshed.id, + installationId: 'installation-2', + platform: 'android', + }); + const expired = await t.context.accessTokens.sign( + t.context.user.id, + authSession.session.id + ); + await t.context.db.userSession.updateMany({ + where: { + userId: t.context.user.id, + sessionId: refreshed.sessionId, + }, + data: { + expiresAt: new Date(Date.now() - 1000), + }, + }); + + const expiredError = await t.throwsAsync(() => + t.context.accessTokens.verify(expired.token) + ); + t.is(expiredError?.message, 'AUTH_SESSION_EXPIRED'); + } +); + +for (const expiry of ['idleExpiresAt', 'absoluteExpiresAt'] as const) { + test.serial( + `should reject access jwt after auth-session ${expiry}`, + async t => { + const signed = await t.context.accessTokens.sign( + t.context.user.id, + t.context.authSessionId + ); + await t.context.db.authSession.update({ + where: { id: t.context.authSessionId }, + data: { [expiry]: new Date(Date.now() - 1000) }, + }); + + const error = await t.throwsAsync(() => + t.context.accessTokens.verify(signed.token) + ); + t.is(error?.message, 'AUTH_SESSION_EXPIRED'); + } + ); +} diff --git a/packages/backend/server/src/__tests__/auth/auth-session.spec.ts b/packages/backend/server/src/__tests__/auth/auth-session.spec.ts new file mode 100644 index 0000000000..3c7882cab8 --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/auth-session.spec.ts @@ -0,0 +1,662 @@ +import { createHash } from 'node:crypto'; + +import { Prisma, PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { Cache, ConfigFactory, EventBus } from '../../base'; +import { + AuthModule, + AuthService, + AuthSessionService, + AuthSigningKeyRing, +} from '../../core/auth'; +import { Models } from '../../models'; +import { createTestingApp, TestingApp } from '../utils'; + +const test = ava as TestFn<{ + app: TestingApp; + auth: AuthService; + cache: Cache; + config: ConfigFactory; + db: PrismaClient; + event: EventBus; + authSessions: AuthSessionService; + signingKeys: AuthSigningKeyRing; + models: Models; + userId: string; + userSessionId: string; +}>; + +test.before(async t => { + const app = await createTestingApp({ imports: [AuthModule] }); + t.context.app = app; + t.context.auth = app.get(AuthService); + t.context.cache = app.get(Cache); + t.context.config = app.get(ConfigFactory); + t.context.db = app.get(PrismaClient); + t.context.event = app.get(EventBus); + t.context.authSessions = app.get(AuthSessionService); + t.context.signingKeys = app.get(AuthSigningKeyRing); + t.context.models = app.get(Models); +}); + +test.beforeEach(async t => { + await t.context.app.initTestingDB(); + t.context.config.override({ + auth: { + token: { + accessTokenTtl: 900, + refreshIdleTtl: 30 * 24 * 60 * 60, + refreshAbsoluteTtl: 180 * 24 * 60 * 60, + refreshGracePeriod: 30, + refreshRetention: 30 * 24 * 60 * 60, + }, + }, + }); + const user = await t.context.auth.signUp('auth-session@affine.pro', '1'); + const userSession = await t.context.auth.createUserSession(user.id); + t.context.userId = user.id; + t.context.userSessionId = userSession.id; +}); + +test.after.always(async t => { + await t.context.app.close(); +}); + +test.serial('stores only a hash of the refresh token', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'ios', + }); + const persisted = await t.context.db.authRefreshToken.findFirstOrThrow(); + + t.regex(issued.refreshToken, /^aff_rt_v1\.[^.]+\.[^.]+$/); + t.not(persisted.secretHash, issued.refreshToken); + t.false(issued.refreshToken.includes(persisted.secretHash)); + const secret = issued.refreshToken.split('.')[2]; + t.is( + persisted.secretHash, + createHash('sha256').update(Buffer.from(secret, 'base64url')).digest('hex') + ); +}); + +test.serial('emits token-free security policy events', async t => { + const events: Events['auth.security.detected'][] = []; + const dispose = t.context.event.on('auth.security.detected', event => { + events.push(event); + }); + + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-security', + platform: 'ios', + }); + await t.context.authSessions.revokeUserSessions( + t.context.userId, + 'security_action' + ); + await new Promise(resolve => setImmediate(resolve)); + dispose(); + + t.deepEqual( + events.map(event => event.type), + ['new_device_login', 'sessions_revoked'] + ); + t.false(JSON.stringify(events).includes(issued.refreshToken)); +}); + +test.serial('emits new-device policy only for a new installation', async t => { + const events: Events['auth.security.detected'][] = []; + const dispose = t.context.event.on('auth.security.detected', event => { + if (event.type === 'new_device_login') events.push(event); + }); + const input = { + userSessionId: t.context.userSessionId, + installationId: 'same-installation', + platform: 'ios', + }; + + await t.context.authSessions.create(input); + const secondParent = await t.context.auth.createUserSession(t.context.userId); + await t.context.authSessions.create({ + ...input, + userSessionId: secondParent.id, + }); + await new Promise(resolve => setImmediate(resolve)); + dispose(); + + t.is(events.length, 1); +}); + +test.serial( + 'emits one new-device event for concurrent session creation', + async t => { + const events: Events['auth.security.detected'][] = []; + const dispose = t.context.event.on('auth.security.detected', event => { + if (event.type === 'new_device_login') events.push(event); + }); + const secondParent = await t.context.auth.createUserSession( + t.context.userId + ); + + await Promise.all([ + t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'concurrent-installation', + platform: 'ios', + }), + t.context.authSessions.create({ + userSessionId: secondParent.id, + installationId: 'concurrent-installation', + platform: 'ios', + }), + ]); + await new Promise(resolve => setImmediate(resolve)); + dispose(); + + t.is(events.length, 1); + } +); + +test.serial( + 'revokes auth sessions before user deletion or disable', + async t => { + await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'deleted-user-device', + platform: 'android', + }); + const detected = t.context.event.waitFor('auth.security.detected', 1000); + + await t.context.models.user.delete(t.context.userId); + const [event] = (await detected) as [Events['auth.security.detected']]; + + t.is(event.type, 'sessions_revoked'); + t.is(event.reason, 'user_deleted_or_disabled'); + t.is( + await t.context.db.authSession.count({ + where: { userSession: { userId: t.context.userId } }, + }), + 0 + ); + } +); + +test.serial('rotates refresh tokens and revokes reuse after grace', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'android', + }); + + const concurrent = await Promise.all([ + t.context.authSessions.refresh(issued.refreshToken, '0.27.0'), + t.context.authSessions.refresh(issued.refreshToken, '0.27.0'), + ]); + t.deepEqual( + concurrent.map(result => result.status), + ['rotated', 'rotated'] + ); + if ( + concurrent[0].status !== 'rotated' || + concurrent[1].status !== 'rotated' + ) { + return; + } + t.is(concurrent[0].refreshToken, concurrent[1].refreshToken); + const next = await t.context.authSessions.refresh( + concurrent[0].refreshToken, + '0.27.0' + ); + t.is(next.status, 'rotated'); + + const replay = await t.context.authSessions.refresh(issued.refreshToken); + t.is(replay.status, 'reused'); + if (replay.status === 'reused') { + t.is(replay.code, 'REFRESH_TOKEN_REUSED'); + } + + const session = await t.context.db.authSession.findUniqueOrThrow({ + where: { id: issued.session.id }, + }); + t.truthy(session.revokedAt); + t.is(session.revokeReason, 'refresh_token_reused'); +}); + +test.serial('bounds a refresh-token concurrency storm', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'android', + }); + + const results = await Promise.all( + Array.from({ length: 100 }, () => + t.context.authSessions.refresh(issued.refreshToken, '0.27.0') + ) + ); + const rotated = results.filter(result => result.status === 'rotated'); + t.is(rotated.length, 2); + if (rotated.length !== 2) return; + t.is(rotated[0].refreshToken, rotated[1].refreshToken); + t.true( + results.some( + result => result.status === 'reused' || result.status === 'revoked' + ) + ); + + const session = await t.context.db.authSession.findUniqueOrThrow({ + where: { id: issued.session.id }, + }); + t.is(session.revokeReason, 'refresh_token_reused'); +}); + +test.serial( + 'does not return an unpersisted token when the grace cache is lost', + async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'ios', + }); + const sourceTokenId = issued.refreshToken.split('.')[1]; + const rotated = await t.context.authSessions.refresh(issued.refreshToken); + t.is(rotated.status, 'rotated'); + if (rotated.status !== 'rotated') return; + + await t.context.cache.delete(`auth:session-refresh:${sourceTokenId}`); + const retry = await t.context.authSessions.refresh(issued.refreshToken); + t.deepEqual(retry, { + status: 'temporarily_unavailable', + code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', + }); + + t.is( + (await t.context.authSessions.refresh(rotated.refreshToken)).status, + 'rotated' + ); + } +); + +test.serial('rejects expired and revoked auth sessions', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'electron', + }); + await t.context.db.authSession.update({ + where: { id: issued.session.id }, + data: { idleExpiresAt: new Date(Date.now() - 1000) }, + }); + + const expired = await t.context.authSessions.refresh(issued.refreshToken); + t.is(expired.status, 'expired'); + if (expired.status === 'expired') { + t.is(expired.code, 'AUTH_SESSION_EXPIRED'); + } + + await t.context.authSessions.revoke(issued.session.id, 'user_action'); + const revoked = await t.context.authSessions.refresh(issued.refreshToken); + t.is(revoked.status, 'revoked'); + if (revoked.status === 'revoked') { + t.is(revoked.code, 'AUTH_SESSION_REVOKED'); + } +}); + +test.serial('revokes refresh for a disabled user', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-disabled', + platform: 'ios', + }); + await t.context.db.user.update({ + where: { id: t.context.userId }, + data: { disabled: true }, + }); + + const result = await t.context.authSessions.refresh(issued.refreshToken); + t.deepEqual(result, { + status: 'revoked', + code: 'AUTH_SESSION_REVOKED', + }); + const session = await t.context.db.authSession.findUniqueOrThrow({ + where: { id: issued.session.id }, + }); + t.is(session.revokeReason, 'user_disabled'); +}); + +for (const expiry of [ + 'absolute session', + 'refresh token', + 'parent user session', +] as const) { + test.serial(`rejects ${expiry} expiry`, async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'electron', + }); + const expiredAt = new Date(Date.now() - 1000); + if (expiry === 'absolute session') { + await t.context.db.authSession.update({ + where: { id: issued.session.id }, + data: { absoluteExpiresAt: expiredAt }, + }); + } else if (expiry === 'refresh token') { + await t.context.db.authRefreshToken.updateMany({ + where: { authSessionId: issued.session.id }, + data: { expiresAt: expiredAt }, + }); + } else { + await t.context.db.userSession.update({ + where: { id: t.context.userSessionId }, + data: { expiresAt: expiredAt }, + }); + } + + const result = await t.context.authSessions.refresh(issued.refreshToken); + t.deepEqual(result, { + status: 'expired', + code: 'AUTH_SESSION_EXPIRED', + }); + }); +} + +test.serial( + 'returns a stable code for malformed refresh credentials', + async t => { + const result = await t.context.authSessions.refresh('invalid'); + t.deepEqual(result, { + status: 'invalid', + code: 'REFRESH_TOKEN_INVALID', + }); + } +); + +test.serial( + 'deleting the user session cascades to auth-session credentials', + async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'ios', + }); + + await t.context.db.userSession.delete({ + where: { id: t.context.userSessionId }, + }); + + t.is( + await t.context.db.authSession.findUnique({ + where: { id: issued.session.id }, + }), + null + ); + t.is(await t.context.db.authRefreshToken.count(), 0); + } +); + +test.serial('cleans auth sessions after the retention window', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'ios', + }); + await t.context.db.authSession.update({ + where: { id: issued.session.id }, + data: { + absoluteExpiresAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000), + }, + }); + + t.is(await t.context.authSessions.cleanup(), 1); + t.is(await t.context.db.authSession.count(), 0); + t.is(await t.context.db.authRefreshToken.count(), 0); +}); + +test.serial('rolls back a failed refresh generation insert', async t => { + const issued = await t.context.authSessions.create({ + userSessionId: t.context.userSessionId, + installationId: 'installation-1', + platform: 'ios', + }); + const current = await t.context.db.authRefreshToken.findFirstOrThrow({ + where: { authSessionId: issued.session.id }, + }); + + await t.throwsAsync(() => + t.context.models.authSession.rotate({ + id: current.id, + secretHash: current.secretHash, + now: new Date(), + idleExpiresAt: new Date(Date.now() + 60_000), + graceMs: 30_000, + next: { + id: current.id, + secretHash: current.secretHash, + expiresAt: new Date(Date.now() + 60_000), + }, + }) + ); + + const after = await t.context.db.authRefreshToken.findUniqueOrThrow({ + where: { id: current.id }, + }); + t.is(after.usedAt, null); + t.is(after.replacedById, null); +}); + +test.serial( + 'selects one active signing key and keeps retiring keys readable', + async t => { + const retiredAt = new Date(); + const verifyUntil = new Date(retiredAt.getTime() + (900 + 30) * 1000); + await setSigningKeys(t.context.db, [ + { + id: 'active', + secret: Buffer.alloc(32, 1).toString('base64url'), + status: 'active', + source: 'auto', + }, + { + id: 'retiring', + secret: Buffer.alloc(32, 2).toString('base64url'), + status: 'retiring', + source: 'admin', + retiredAt: retiredAt.toISOString(), + verifyUntil: verifyUntil.toISOString(), + }, + ]); + await t.context.signingKeys.onSigningKeysChanged(); + + t.is((await t.context.signingKeys.active()).id, 'active'); + t.is((await t.context.signingKeys.verify('retiring'))?.status, 'retiring'); + t.is( + await t.context.signingKeys.verify( + 'retiring', + new Date(verifyUntil.getTime() + 1) + ), + undefined + ); + await setSigningKeys(t.context.db, [ + { + id: 'active', + secret: Buffer.alloc(32, 1).toString('base64url'), + status: 'active', + source: 'auto', + }, + ]); + await t.context.signingKeys.onSigningKeysChanged(); + t.is(await t.context.signingKeys.verify('retiring'), undefined); + t.is(await t.context.signingKeys.verify('missing'), undefined); + } +); + +test.serial('rejects ambiguous signing key rings', async t => { + await setSigningKeys(t.context.db, [ + { + id: 'one', + secret: Buffer.alloc(32, 1).toString('base64url'), + status: 'active', + source: 'auto', + }, + { + id: 'two', + secret: Buffer.alloc(32, 2).toString('base64url'), + status: 'active', + source: 'admin', + }, + ]); + + await t.throwsAsync(() => t.context.signingKeys.onSigningKeysChanged(), { + message: 'Auth session requires exactly one active signing key.', + }); +}); + +test.serial( + 'rejects invalid signing key material and retirement data', + async t => { + const cases = [ + { + name: 'short key', + keys: [ + { + id: 'short', + secret: Buffer.alloc(8).toString('base64url'), + status: 'active' as const, + source: 'auto' as const, + }, + ], + message: /at least 32 bytes/, + }, + { + name: 'non-canonical key', + keys: [ + { + id: 'non-canonical', + secret: `${Buffer.alloc(32).toString('base64url')}=`, + status: 'active' as const, + source: 'auto' as const, + }, + ], + message: /canonical base64url/, + }, + { + name: 'duplicate id', + keys: [ + { + id: 'duplicate', + secret: Buffer.alloc(32, 1).toString('base64url'), + status: 'active' as const, + source: 'auto' as const, + }, + { + id: 'duplicate', + secret: Buffer.alloc(32, 2).toString('base64url'), + status: 'active' as const, + source: 'admin' as const, + }, + ], + message: /ids must be unique/, + }, + { + name: 'retiring without deadline', + keys: [ + { + id: 'active', + secret: Buffer.alloc(32, 1).toString('base64url'), + status: 'active' as const, + source: 'auto' as const, + }, + { + id: 'retiring', + secret: Buffer.alloc(32, 2).toString('base64url'), + status: 'retiring' as const, + source: 'admin' as const, + }, + ], + message: /requires retiredAt and verifyUntil/, + }, + ]; + + for (const testCase of cases) { + await setSigningKeys(t.context.db, testCase.keys); + await t.throwsAsync(() => t.context.signingKeys.onSigningKeysChanged(), { + message: testCase.message, + }); + } + } +); + +test.serial('persists one stable database signing key', async t => { + await t.context.db.appConfig.deleteMany({ + where: { id: 'auth.session.signingKeys' }, + }); + await t.context.signingKeys.onConfigInit(); + const first = (await t.context.signingKeys.active()).id; + const stored = await t.context.db.appConfig.findUniqueOrThrow({ + where: { id: 'auth.session.signingKeys' }, + }); + t.is((stored.value as Array<{ id: string }>)[0]?.id, first); + + await t.context.signingKeys.onConfigInit(); + t.is((await t.context.signingKeys.active()).id, first); +}); + +test.serial('rotates and safely deletes signing keys', async t => { + await t.context.signingKeys.onConfigInit(); + const previous = (await t.context.signingKeys.active()).id; + + const rotated = await t.context.signingKeys.rotate( + t.context.userId, + previous + ); + const active = rotated.find(key => key.status === 'active'); + const retiring = rotated.find(key => key.id === previous); + t.truthy(active); + t.is(retiring?.status, 'retiring'); + t.truthy(await t.context.signingKeys.verify(previous)); + t.false(JSON.stringify(rotated).includes('secret')); + await t.throwsAsync(t.context.signingKeys.rotate(t.context.userId, previous)); + await t.throwsAsync(t.context.signingKeys.delete(t.context.userId, previous)); + + const stored = await t.context.db.appConfig.findUniqueOrThrow({ + where: { id: 'auth.session.signingKeys' }, + }); + const ring = stored.value as Array>; + const verifyUntil = new Date(Date.now() - 1); + const retiredAt = new Date(verifyUntil.getTime() - 931_000); + await t.context.db.appConfig.update({ + where: { id: stored.id }, + data: { + value: ring.map(key => + key.id === previous + ? { + ...key, + retiredAt: retiredAt.toISOString(), + verifyUntil: verifyUntil.toISOString(), + } + : key + ) as Prisma.InputJsonValue, + }, + }); + await t.context.signingKeys.onSigningKeysChanged(); + const afterDelete = await t.context.signingKeys.delete( + t.context.userId, + previous + ); + t.false(afterDelete.some(key => key.id === previous)); + t.is(afterDelete.filter(key => key.status === 'active').length, 1); +}); + +async function setSigningKeys(db: PrismaClient, keys: unknown) { + await db.appConfig.upsert({ + where: { id: 'auth.session.signingKeys' }, + update: { value: keys as Prisma.InputJsonValue }, + create: { + id: 'auth.session.signingKeys', + value: keys as Prisma.InputJsonValue, + }, + }); +} diff --git a/packages/backend/server/src/__tests__/auth/auth.e2e.ts b/packages/backend/server/src/__tests__/auth/auth.e2e.ts index 46b217b994..2d12402f1a 100644 --- a/packages/backend/server/src/__tests__/auth/auth.e2e.ts +++ b/packages/backend/server/src/__tests__/auth/auth.e2e.ts @@ -1,9 +1,11 @@ import { randomBytes } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; import supertest from 'supertest'; +import { AuthService, AuthSessionService } from '../../core/auth'; import { changeEmail, changePassword, @@ -108,6 +110,12 @@ test('set and change password', async t => { const u1Email = 'u1@affine.pro'; const u1 = await app.signupV1(u1Email); + const parent = await app.get(AuthService).createUserSession(u1.id); + const authSession = await app.get(AuthSessionService).create({ + userSessionId: parent.id, + installationId: 'password-change-device', + platform: 'ios', + }); await sendSetPasswordEmail(app, u1Email, '/password-change'); const setPasswordMail = app.mails.last('SetPassword'); @@ -130,6 +138,12 @@ test('set and change password', async t => { ); t.true(success, 'failed to change password'); + t.is( + await app.get(PrismaClient).authSession.count({ + where: { id: authSession.session.id }, + }), + 0 + ); let user = await currentUser(app); diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts index 4dc9886674..55fa439851 100644 --- a/packages/backend/server/src/__tests__/auth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts @@ -4,6 +4,7 @@ import { IncomingMessage } from 'node:http'; import { HttpStatus } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import ava, { ExecutionContext, TestFn } from 'ava'; +import jwt from 'jsonwebtoken'; import Sinon from 'sinon'; import supertest from 'supertest'; @@ -13,8 +14,10 @@ import { getRequestHeader, parseCookies as safeParseCookies, } from '../../base/utils/request'; +import { AuthSessionService } from '../../core/auth/auth-session'; import { MagicLinkAuthService } from '../../core/auth/magic-link'; import { AuthService } from '../../core/auth/service'; +import { mintChallengeResponse } from '../../native'; import { createTestingApp, currentUser, @@ -25,6 +28,7 @@ import { const test = ava as TestFn<{ auth: AuthService; magicLink: MagicLinkAuthService; + authSessions: AuthSessionService; db: PrismaClient; config: ConfigFactory; app: TestingApp; @@ -35,6 +39,7 @@ test.before(async t => { t.context.auth = app.get(AuthService); t.context.magicLink = app.get(MagicLinkAuthService); + t.context.authSessions = app.get(AuthSessionService); t.context.db = app.get(PrismaClient); t.context.config = app.get(ConfigFactory); t.context.app = app; @@ -45,6 +50,17 @@ test.beforeEach(async t => { await t.context.app.initTestingDB(); t.context.config.override({ auth: { allowSignup: true, requireEmailDomainVerification: false }, + captcha: { + enabled: false, + config: { + turnstile: { + secret: '', + siteKey: '', + action: 'auth-sign-in', + }, + challenge: { bits: 20 }, + }, + }, }); }); @@ -70,6 +86,156 @@ test('should be able to sign in with credential', async t => { t.is(session?.id, u1.id); }); +test('should accept one Hashcash proof and reject its replay', async t => { + const { app, config } = t.context; + const user = await app.createUser('hashcash-login@affine.pro'); + config.override({ + captcha: { + enabled: true, + config: { + turnstile: { + secret: 'secret', + siteKey: 'site-key', + action: 'auth-sign-in', + }, + challenge: { bits: 20 }, + }, + }, + }); + const challenge = await app + .GET('/api/auth/captcha') + .set('x-affine-client-kind', 'native') + .expect(200); + t.is(challenge.body.provider, 'hashcash'); + const token = await mintChallengeResponse(challenge.body.resource, 20); + if (!token) throw new Error('Failed to mint Hashcash proof'); + const request = () => + app + .POST('/api/auth/sign-in') + .set('x-captcha-provider', 'hashcash') + .set('x-captcha-challenge', challenge.body.challenge) + .set('x-captcha-token', token) + .send({ email: user.email, password: user.password }); + + await request().expect(200); + const replay = await request().expect(400); + t.is(replay.body.name, 'CAPTCHA_VERIFICATION_FAILED'); +}); + +test('should validate Turnstile action and reject query credentials', async t => { + const { app, config } = t.context; + const user = await app.createUser('turnstile-login@affine.pro'); + config.override({ + captcha: { + enabled: true, + config: { + turnstile: { + secret: 'secret', + siteKey: 'site-key', + action: 'auth-sign-in', + }, + challenge: { bits: 20 }, + }, + }, + }); + const publicConfig = await app.GET('/api/auth/captcha').expect(200); + t.deepEqual(publicConfig.body, { + provider: 'turnstile', + siteKey: 'site-key', + action: 'auth-sign-in', + }); + const verify = Sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + success: true, + hostname: config.config.server.host, + action: 'auth-sign-in', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + await app + .POST('/api/auth/sign-in') + .set('x-captcha-provider', 'turnstile') + .set('x-captcha-token', 'turnstile-token') + .send({ email: user.email, password: user.password }) + .expect(200); + t.true(verify.calledOnce); + verify.restore(); + + await app + .POST('/api/auth/sign-in?provider=turnstile&token=turnstile-token') + .send({ email: user.email, password: user.password }) + .expect(400); +}); + +test('should reject a Turnstile response for another action', async t => { + const { app, config } = t.context; + const user = await app.createUser('turnstile-action@affine.pro'); + config.override({ + captcha: { + enabled: true, + config: { + turnstile: { + secret: 'secret', + siteKey: 'site-key', + action: 'auth-sign-in', + }, + challenge: { bits: 20 }, + }, + }, + }); + const verify = Sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + success: true, + hostname: config.config.server.host, + action: 'another-action', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + + const response = await app + .POST('/api/auth/sign-in') + .set('x-captcha-provider', 'turnstile') + .set('x-captcha-token', 'turnstile-token') + .send({ email: user.email, password: user.password }) + .expect(400); + verify.restore(); + t.is(response.body.name, 'CAPTCHA_VERIFICATION_FAILED'); +}); + +test('should expose a retryable error when Turnstile is unavailable', async t => { + const { app, config } = t.context; + const user = await app.createUser('turnstile-unavailable@affine.pro'); + config.override({ + captcha: { + enabled: true, + config: { + turnstile: { + secret: 'secret', + siteKey: 'site-key', + action: 'auth-sign-in', + }, + challenge: { bits: 20 }, + }, + }, + }); + const verify = Sinon.stub(globalThis, 'fetch').rejects( + new Error('siteverify unavailable') + ); + + const response = await app + .POST('/api/auth/sign-in') + .set('x-captcha-provider', 'turnstile') + .set('x-captcha-token', 'turnstile-token') + .send({ email: user.email, password: user.password }) + .expect(504); + verify.restore(); + t.is(response.body.name, 'NETWORK_ERROR'); +}); + test('should not cache auth session response', async t => { const { app } = t.context; @@ -78,15 +244,23 @@ test('should not cache auth session response', async t => { t.is(res.headers['cache-control'], 'no-store'); }); -async function exchangeSession(app: TestingApp, code: string) { +async function exchangeSession( + app: TestingApp, + code: string, + installationId = '00000000-0000-4000-8000-000000000001' +) { return await supertest(app.getHttpServer()) - .post('/api/auth/native/exchange') + .post('/api/auth/session/exchange') .set('x-affine-client-kind', 'native') - .send({ code }) + .send({ + code, + installationId, + platform: 'electron', + }) .expect(201); } -function assertClearsNativeAuthCookies( +function assertClearsClientAuthCookies( t: ExecutionContext, res: supertest.Response ) { @@ -119,11 +293,302 @@ test('should issue exchange code only for native credential sign in', async t => t.is(res.body.id, u1.id); t.truthy(res.body.exchangeCode); - assertClearsNativeAuthCookies(t, res); + assertClearsClientAuthCookies(t, res); const exchangeRes = await exchangeSession(app, res.body.exchangeCode); - t.truthy(exchangeRes.body.token); - t.truthy(exchangeRes.body.expiresAt); + t.truthy(exchangeRes.body.accessToken); + t.is(exchangeRes.body.tokenType, 'Bearer'); + t.is(exchangeRes.body.expiresIn, 15 * 60); + t.truthy(exchangeRes.body.refreshToken); + t.truthy(exchangeRes.body.refreshExpiresAt); + t.is(exchangeRes.headers['cache-control'], 'no-store'); + t.is(exchangeRes.headers.pragma, 'no-cache'); + t.falsy(exchangeRes.body.token); + t.falsy(exchangeRes.body.expiresAt); + + const decoded = jwt.decode(exchangeRes.body.accessToken, { complete: true }); + t.truthy(decoded); + if (!decoded || typeof decoded.payload === 'string') return; + t.is(decoded.payload.typ, 'session_access'); + t.is(decoded.payload.sid, exchangeRes.body.session.id); + t.is((decoded.payload.exp ?? 0) - (decoded.payload.iat ?? 0), 15 * 60); + t.is(typeof decoded.header.kid, 'string'); +}); + +test('should rotate token pairs and manage auth sessions', async t => { + const { app } = t.context; + const user = await app.createUser('token-lifecycle@affine.pro'); + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const issued = await exchangeSession(app, signIn.body.exchangeCode); + + const refreshed = await app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ + refreshToken: issued.body.refreshToken, + }) + .expect(201); + t.is(refreshed.headers['cache-control'], 'no-store'); + t.is(refreshed.headers.pragma, 'no-cache'); + t.not(refreshed.body.refreshToken, issued.body.refreshToken); + t.is(refreshed.body.session.id, issued.body.session.id); + + const sessions = await app + .GET('/api/auth/sessions') + .set('Authorization', `Bearer ${refreshed.body.accessToken}`) + .expect(200); + t.is(sessions.headers['cache-control'], 'no-store'); + t.is(sessions.headers.pragma, 'no-cache'); + t.is(sessions.body.length, 1); + t.is(sessions.body[0].id, issued.body.session.id); + t.is(sessions.body[0].platform, 'electron'); + t.true(sessions.body[0].current); + + const revoked = await app + .DELETE(`/api/auth/sessions/${issued.body.session.id}`) + .set('Authorization', `Bearer ${refreshed.body.accessToken}`) + .expect(200); + t.is(revoked.headers['cache-control'], 'no-store'); + t.is(revoked.headers.pragma, 'no-cache'); + const rejected = await app + .GET('/api/auth/session') + .set('Authorization', `Bearer ${refreshed.body.accessToken}`) + .expect(401); + t.is(rejected.body.code, 'AUTH_SESSION_REVOKED'); +}); + +test('should revoke another device immediately and revoke all devices', async t => { + const { app } = t.context; + const user = await app.createUser('device-lifecycle@affine.pro'); + const firstSignIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const first = await exchangeSession( + app, + firstSignIn.body.exchangeCode, + '00000000-0000-4000-8000-000000000011' + ); + const secondSignIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const second = await exchangeSession( + app, + secondSignIn.body.exchangeCode, + '00000000-0000-4000-8000-000000000012' + ); + await t.context.db.authSession.update({ + where: { id: first.body.session.id }, + data: { createdAt: new Date(Date.now() - 10 * 60 * 1000) }, + }); + + await app + .DELETE(`/api/auth/sessions/${second.body.session.id}`) + .set('Authorization', `Bearer ${first.body.accessToken}`) + .expect(200); + await app + .GET('/api/auth/session') + .set('Authorization', `Bearer ${second.body.accessToken}`) + .expect(401); + await app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: second.body.refreshToken }) + .expect(401); + + await t.context.db.authSession.update({ + where: { id: first.body.session.id }, + data: { createdAt: new Date() }, + }); + + await app + .POST('/api/auth/sessions/revoke-all') + .set('Authorization', `Bearer ${first.body.accessToken}`) + .expect(201); + await app + .GET('/api/auth/session') + .set('Authorization', `Bearer ${first.body.accessToken}`) + .expect(401); + t.pass(); +}); + +test('should require csrf and revoke cookie plus auth sessions', async t => { + const { app, db } = t.context; + const user = await app.createUser('cookie-revoke-all@affine.pro'); + const signedIn = await app + .POST('/api/auth/sign-in') + .send({ email: user.email, password: user.password }) + .expect(200); + const cookies = signedIn.get('Set-Cookie') ?? []; + const parsed = parseCookies(signedIn); + const parent = await app.get(AuthService).createUserSession(user.id); + await app.get(AuthSessionService).create({ + userSessionId: parent.id, + installationId: 'cookie-managed-device', + platform: 'electron', + }); + app.clearAuth(); + + await app + .POST('/api/auth/sessions/revoke-all') + .set('Cookie', cookies) + .expect(403); + await app + .POST('/api/auth/sessions/revoke-all') + .set('Cookie', cookies) + .set('x-affine-csrf-token', parsed[AuthService.csrfCookieName]) + .expect(201); + + t.is(await db.userSession.count({ where: { userId: user.id } }), 0); + t.is( + await db.authSession.count({ + where: { userSession: { userId: user.id } }, + }), + 0 + ); +}); + +test('should idempotently revoke a auth session with its refresh token', async t => { + const { app } = t.context; + const user = await app.createUser('token-revoke@affine.pro'); + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const issued = await exchangeSession(app, signIn.body.exchangeCode); + + for (let attempt = 0; attempt < 2; attempt++) { + const revoked = await app + .POST('/api/auth/session/revoke') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: issued.body.refreshToken }) + .expect(201); + t.is(revoked.headers['cache-control'], 'no-store'); + t.is(revoked.headers.pragma, 'no-cache'); + } + const rejected = await app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: issued.body.refreshToken }) + .expect(401); + t.is(rejected.body.code, 'AUTH_SESSION_REVOKED'); +}); + +test('should strictly validate auth-session refresh and revoke bodies', async t => { + const refresh = await t.context.app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: 'invalid', extra: true }) + .expect(400); + t.is(refresh.body.name, 'VALIDATION_ERROR'); + + const revoke = await t.context.app + .POST('/api/auth/session/revoke') + .set('x-affine-client-kind', 'native') + .send({ extra: true }) + .expect(400); + t.is(revoke.body.name, 'VALIDATION_ERROR'); +}); + +test('should roll back user-session issuance when auth-session issuance fails', async t => { + const { app } = t.context; + const user = await app.createUser('token-rollback@affine.pro'); + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + t.is(await t.context.db.userSession.count({ where: { userId: user.id } }), 0); + const sessionCount = await t.context.db.session.count(); + + const createStub = Sinon.stub(t.context.authSessions, 'create').rejects( + new Error('issuance failure') + ); + await supertest(app.getHttpServer()) + .post('/api/auth/session/exchange') + .set('x-affine-client-kind', 'native') + .send({ + code: signIn.body.exchangeCode, + installationId: '00000000-0000-4000-8000-000000000002', + platform: 'ios', + }) + .expect(500); + createStub.restore(); + t.is(await t.context.db.userSession.count({ where: { userId: user.id } }), 0); + t.is(await t.context.db.session.count(), sessionCount); +}); + +for (const userState of ['disabled', 'deleted'] as const) { + test(`should reject exchange after the user is ${userState}`, async t => { + const { app, db } = t.context; + const user = await app.createUser(`token-${userState}@affine.pro`); + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + if (userState === 'disabled') { + await db.user.update({ + where: { id: user.id }, + data: { disabled: true }, + }); + } else { + await db.user.delete({ where: { id: user.id } }); + } + + const rejected = await supertest(app.getHttpServer()) + .post('/api/auth/session/exchange') + .set('x-affine-client-kind', 'native') + .send({ + code: signIn.body.exchangeCode, + installationId: + userState === 'disabled' + ? '00000000-0000-4000-8000-000000000003' + : '00000000-0000-4000-8000-000000000004', + platform: 'ios', + }) + .expect(400); + t.is(rejected.body.name, 'INVALID_AUTH_STATE'); + t.is(await db.userSession.count({ where: { userId: user.id } }), 0); + t.is(await db.authSession.count(), 0); + }); +} + +test('should allow an authenticated device to revoke its session', async t => { + const { app, db } = t.context; + const user = await app.createUser('token-recent-auth@affine.pro'); + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const issued = await exchangeSession(app, signIn.body.exchangeCode); + await db.authSession.update({ + where: { id: issued.body.session.id }, + data: { createdAt: new Date(Date.now() - 10 * 60 * 1000) }, + }); + const refreshed = await app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: issued.body.refreshToken }) + .expect(201); + + await app + .DELETE(`/api/auth/sessions/${issued.body.session.id}`) + .set('Authorization', `Bearer ${refreshed.body.accessToken}`) + .expect(200); + const session = await db.authSession.findUniqueOrThrow({ + where: { id: issued.body.session.id }, + }); + t.truthy(session.revokedAt); }); test('should not issue jwt for browser-origin credential sign in', async t => { @@ -161,6 +626,109 @@ test('should write legacy auth cookies when signing in with credential', async t t.truthy(cookies[AuthService.csrfCookieName]); }); +test('should preserve Electron 0.26 cookie authentication', async t => { + const { app, db } = t.context; + const user = await app.createUser('electron-026@affine.pro'); + + const signIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-version', '0.26.7') + .send({ + email: user.email, + password: user.password, + verifyToken: 'legacy-captcha-token', + challenge: 'legacy-captcha-challenge', + }) + .expect(200); + + const cookies = signIn.get('Set-Cookie') ?? []; + t.falsy(signIn.body.exchangeCode); + t.true( + cookies.some(cookie => + cookie.startsWith(`${AuthService.sessionCookieName}=`) + ) + ); + + const session = await app + .GET('/api/auth/session') + .set('x-affine-version', '0.26.7') + .set('Cookie', cookies) + .expect(200); + + t.is(session.body.user.id, user.id); + + const currentUserData = await app.gql<{ currentUser: { id: string } }>(` + query { + currentUser { id } + } + `); + t.is(currentUserData.currentUser.id, user.id); + + const beforeRefresh = await db.userSession.findFirstOrThrow({ + where: { userId: user.id }, + }); + await db.userSession.update({ + where: { id: beforeRefresh.id }, + data: { expiresAt: new Date(Date.now() + 1000) }, + }); + + await app + .GET('/api/auth/session') + .set('x-affine-version', '0.26.7') + .expect(200); + + const afterRefresh = await db.userSession.findUniqueOrThrow({ + where: { id: beforeRefresh.id }, + }); + t.true( + (afterRefresh.expiresAt?.getTime() ?? 0) > + Date.now() + 14 * 24 * 60 * 60 * 1000 + ); + t.is(afterRefresh.refreshClientVersion, '0.26.7'); +}); + +test('should reject an invalid refresh token with a stable code', async t => { + const res = await t.context.app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: 'unavailable' }) + .expect(401); + + t.is(res.body.code, 'REFRESH_TOKEN_INVALID'); +}); + +test('should not fall back to a cookie on auth-session refresh', async t => { + const user = await t.context.app.createUser('refresh-cookie@affine.pro'); + await t.context.app + .POST('/api/auth/sign-in') + .send({ email: user.email, password: user.password }) + .expect(200); + const res = await t.context.app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken: 'invalid' }) + .expect(401); + + t.is(res.body.code, 'REFRESH_TOKEN_INVALID'); +}); + +test('should rate limit refresh attempts by token selector', async t => { + const refreshToken = `aff_rt_v1.${randomUUID()}.invalid`; + for (let attempt = 0; attempt < 30; attempt++) { + await t.context.app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken }) + .expect(401); + } + await t.context.app + .POST('/api/auth/session/refresh') + .set('x-affine-client-kind', 'native') + .send({ refreshToken }) + .expect(429); + t.pass(); +}); + test('should record sign in client version when header is provided', async t => { const { app, db } = t.context; @@ -492,7 +1060,7 @@ test('should be able to sign out with jwt without csrf', async t => { .send({ email: u1.email, password: u1.password }) .expect(200); const token = (await exchangeSession(app, signInRes.body.exchangeCode)).body - .token; + .accessToken; await supertest(app.getHttpServer()) .post('/api/auth/sign-out') @@ -502,9 +1070,8 @@ test('should be able to sign out with jwt without csrf', async t => { const sessionRes = await supertest(app.getHttpServer()) .get('/api/auth/session') .set('Authorization', `Bearer ${token}`) - .expect(200); - - t.falsy(sessionRes.body.user); + .expect(401); + t.is(sessionRes.body.code, 'AUTH_SESSION_REVOKED'); }); test('should ignore user_id query when signing out with jwt', async t => { @@ -519,7 +1086,7 @@ test('should ignore user_id query when signing out with jwt', async t => { .send({ email: u1.email, password: u1.password }) .expect(200); const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body - .token; + .accessToken; await app .POST('/api/auth/sign-in') .send({ email: u2.email, password: u2.password }) @@ -533,14 +1100,14 @@ test('should ignore user_id query when signing out with jwt', async t => { const u1Session = await supertest(app.getHttpServer()) .get('/api/auth/session') .set('Authorization', `Bearer ${u1Token}`) - .expect(200); - t.falsy(u1Session.body.user); + .expect(401); + t.is(u1Session.body.code, 'AUTH_SESSION_REVOKED'); const cookieSession = await app.GET('/api/auth/session').expect(200); t.is(cookieSession.body.user.id, u2.id); }); -test('should reuse jwt session when signing in another account without cookies', async t => { +test('should isolate auth sessions when signing in another account', async t => { const { app } = t.context; const u1 = await app.createUser('u1@affine.pro'); @@ -552,13 +1119,15 @@ test('should reuse jwt session when signing in another account without cookies', .send({ email: u1.email, password: u1.password }) .expect(200); const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body - .token; + .accessToken; const u2SignIn = await supertest(app.getHttpServer()) .post('/api/auth/sign-in') .set('Authorization', `Bearer ${u1Token}`) + .set('x-affine-client-kind', 'native') .send({ email: u2.email, password: u2.password }) .expect(200); + await exchangeSession(app, u2SignIn.body.exchangeCode); const u1Session = await t.context.db.userSession.findFirstOrThrow({ where: { userId: u1.id }, @@ -568,7 +1137,7 @@ test('should reuse jwt session when signing in another account without cookies', }); t.is(u2SignIn.body.id, u2.id); - t.is(u2Session.sessionId, u1Session.sessionId); + t.not(u2Session.sessionId, u1Session.sessionId); }); test('should not reuse legacy bearer session id when signing in another account without cookies', async t => { @@ -675,14 +1244,15 @@ test('should sign in desktop app via one-time open-app code', async t => { t.is(exchangeRes.body.id, u1.id); t.truthy(exchangeRes.body.exchangeCode); - assertClearsNativeAuthCookies(t, exchangeRes); + assertClearsClientAuthCookies(t, exchangeRes); const tokenRes = await exchangeSession(app, exchangeRes.body.exchangeCode); - t.truthy(tokenRes.body.token); - t.truthy(tokenRes.body.expiresAt); + t.truthy(tokenRes.body.accessToken); + t.is(tokenRes.body.expiresIn, 15 * 60); + t.truthy(tokenRes.body.refreshToken); const sessionRes = await supertest(app.getHttpServer()) .get('/api/auth/session') - .set('Authorization', `Bearer ${tokenRes.body.token}`) + .set('Authorization', `Bearer ${tokenRes.body.accessToken}`) .expect(200); t.is(sessionRes.body.user?.id, u1.id); diff --git a/packages/backend/server/src/__tests__/auth/guard.spec.ts b/packages/backend/server/src/__tests__/auth/guard.spec.ts index 704f591034..efeded7cf9 100644 --- a/packages/backend/server/src/__tests__/auth/guard.spec.ts +++ b/packages/backend/server/src/__tests__/auth/guard.spec.ts @@ -6,9 +6,10 @@ import request from 'supertest'; import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base'; import { + AccessTokenService, AuthModule, + AuthSessionService, CurrentUser, - JwtSessionService, Public, Session, } from '../../core/auth'; @@ -43,12 +44,14 @@ const test = ava as TestFn<{ app: TestingApp; server: any; auth: AuthService; - jwtSession: JwtSessionService; + accessTokens: AccessTokenService; + authSessions: AuthSessionService; models: Models; db: PrismaClient; config: ConfigFactory; u1: CurrentUser; sessionId: string; + authSessionId: string; }>; test.before(async t => { @@ -60,7 +63,8 @@ test.before(async t => { t.context.app = app; t.context.server = app.getHttpServer(); t.context.auth = app.get(AuthService); - t.context.jwtSession = app.get(JwtSessionService); + t.context.accessTokens = app.get(AccessTokenService); + t.context.authSessions = app.get(AuthSessionService); t.context.models = app.get(Models); t.context.db = app.get(PrismaClient); t.context.config = app.get(ConfigFactory); @@ -81,7 +85,17 @@ test.beforeEach(async t => { t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1'); const session = await t.context.models.session.createSession(); t.context.sessionId = session.id; - await t.context.auth.createUserSession(t.context.u1.id, t.context.sessionId); + const userSession = await t.context.auth.createUserSession( + t.context.u1.id, + t.context.sessionId + ); + t.context.authSessionId = ( + await t.context.authSessions.create({ + userSessionId: userSession.id, + installationId: 'installation-1', + platform: 'ios', + }) + ).session.id; }); test.after.always(async t => { @@ -127,13 +141,12 @@ test('should be able to visit private api with cookie session', async t => { t.is(res.body.user.id, t.context.u1.id); }); -test('should be able to visit private api with legacy bearer session id', async t => { - const res = await request(t.context.server) +test('should reject a legacy bearer session id', async t => { + await request(t.context.server) .get('/private') .set('Authorization', `Bearer ${t.context.sessionId}`) - .expect(HttpStatus.OK); - - t.is(res.body.user.id, t.context.u1.id); + .expect(HttpStatus.UNAUTHORIZED); + t.pass(); }); test('should be able to visit private api with personal access token', async t => { @@ -150,8 +163,11 @@ test('should be able to visit private api with personal access token', async t = t.is(res.body.user.id, t.context.u1.id); }); -test('should be able to visit private api with jwt session', async t => { - const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId); +test('should be able to visit private api with auth-session access jwt', async t => { + const jwt = await t.context.accessTokens.sign( + t.context.u1.id, + t.context.authSessionId + ); const res = await request(t.context.server) .get('/private') @@ -164,7 +180,15 @@ test('should be able to visit private api with jwt session', async t => { test('should prefer bearer jwt over cookie session', async t => { const u2 = await t.context.auth.signUp('u2@affine.pro', '1'); const u2Session = await t.context.auth.createUserSession(u2.id); - const jwt = t.context.jwtSession.sign(u2.id, u2Session.sessionId); + const u2AuthSessionPrincipal = await t.context.authSessions.create({ + userSessionId: u2Session.id, + installationId: 'installation-2', + platform: 'android', + }); + const jwt = await t.context.accessTokens.sign( + u2.id, + u2AuthSessionPrincipal.session.id + ); const res = await request(t.context.server) .get('/private') @@ -176,7 +200,10 @@ test('should prefer bearer jwt over cookie session', async t => { }); test('should reject jwt after its user session is deleted', async t => { - const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId); + const jwt = await t.context.accessTokens.sign( + t.context.u1.id, + t.context.authSessionId + ); await t.context.auth.signOut(t.context.sessionId, t.context.u1.id); @@ -188,7 +215,7 @@ test('should reject jwt after its user session is deleted', async t => { t.pass(); }); -test('should enforce client version for jwt and bearer session id auth', async t => { +test('should enforce client version for auth-session access jwt auth', async t => { t.context.config.override({ client: { versionControl: { @@ -198,47 +225,44 @@ test('should enforce client version for jwt and bearer session id auth', async t }, }); - const cases = [ - { - name: 'jwt', - token: async () => { - const session = await t.context.auth.createUserSession(t.context.u1.id); - return t.context.jwtSession.sign(t.context.u1.id, session.sessionId) - .token; - }, - }, - { - name: 'bearer session id', - token: async () => { - const session = await t.context.auth.createUserSession(t.context.u1.id); - return session.sessionId; - }, - }, - ]; + const session = await t.context.auth.createUserSession(t.context.u1.id); + const authSession = await t.context.authSessions.create({ + userSessionId: session.id, + installationId: 'version-installation', + platform: 'electron', + }); + const token = ( + await t.context.accessTokens.sign(t.context.u1.id, authSession.session.id) + ).token; + const res = await request(t.context.server) + .get('/private') + .set('Authorization', `Bearer ${token}`) + .set('x-affine-version', '0.24.0') + .expect(HttpStatus.FORBIDDEN); - for (const testCase of cases) { - const res = await request(t.context.server) - .get('/private') - .set('Authorization', `Bearer ${await testCase.token()}`) - .set('x-affine-version', '0.24.0') - .expect(HttpStatus.FORBIDDEN); - - t.is( - res.body.message, - 'Unsupported client with version [0.24.0], required version is [>=0.25.0].', - testCase.name - ); - } + t.is( + res.body.message, + 'Unsupported client with version [0.24.0], required version is [>=0.25.0].' + ); }); -test('should fall back to cookie session on public api when jwt is invalid', async t => { +test('should not hide an invalid auth-session jwt behind a cookie session', async t => { const res = await request(t.context.server) .get('/public') .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) .set('Authorization', 'Bearer invalid.jwt.token') - .expect(HttpStatus.OK); + .expect(HttpStatus.UNAUTHORIZED); - t.is(res.body.user.id, t.context.u1.id); + t.is(res.body.code, 'ACCESS_TOKEN_INVALID'); +}); + +test('should return a stable error for invalid jwt on public api', async t => { + const res = await request(t.context.server) + .get('/public') + .set('Authorization', 'Bearer invalid.jwt.token') + .expect(HttpStatus.UNAUTHORIZED); + + t.is(res.body.code, 'ACCESS_TOKEN_INVALID'); }); test('should be able to parse session cookie', async t => { @@ -252,7 +276,7 @@ test('should be able to parse session cookie', async t => { spy.restore(); }); -test('should be able to parse bearer token', async t => { +test('should not parse a legacy bearer session id', async t => { const spy = Sinon.spy(t.context.auth, 'getUserSession'); await request(t.context.server) @@ -260,10 +284,31 @@ test('should be able to parse bearer token', async t => { .auth(t.context.sessionId, { type: 'bearer' }) .expect(200); - t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]); + t.false(spy.called); spy.restore(); }); +test('should expose auth-session version rejection on a public api', async t => { + t.context.config.override({ + client: { + versionControl: { + enabled: true, + requiredVersion: '>=0.25.0', + }, + }, + }); + const token = ( + await t.context.accessTokens.sign(t.context.u1.id, t.context.authSessionId) + ).token; + const res = await request(t.context.server) + .get('/public') + .set('Authorization', `Bearer ${token}`) + .set('x-affine-version', '0.24.0') + .expect(HttpStatus.FORBIDDEN); + + t.is(res.body.name, 'UNSUPPORTED_CLIENT_VERSION'); +}); + test('should be able to refresh session if needed', async t => { await t.context.app.get(PrismaClient).userSession.updateMany({ where: { diff --git a/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts b/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts deleted file mode 100644 index 19deddb9d7..0000000000 --- a/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import ava, { ExecutionContext, TestFn } from 'ava'; -import jwt from 'jsonwebtoken'; - -import { ConfigFactory } from '../../base'; -import { CryptoHelper } from '../../base/helpers'; -import { - AuthModule, - AuthService, - type CurrentUser, - JwtSessionService, -} from '../../core/auth'; -import { Models } from '../../models'; -import { createTestingApp, TestingApp } from '../utils'; - -const test = ava as TestFn<{ - app: TestingApp; - auth: AuthService; - jwtSession: JwtSessionService; - crypto: CryptoHelper; - config: ConfigFactory; - models: Models; - db: PrismaClient; - user: CurrentUser; - sessionId: string; -}>; - -test.before(async t => { - const app = await createTestingApp({ - imports: [AuthModule], - }); - - t.context.app = app; - t.context.auth = app.get(AuthService); - t.context.jwtSession = app.get(JwtSessionService); - t.context.crypto = app.get(CryptoHelper); - t.context.config = app.get(ConfigFactory); - t.context.models = app.get(Models); - t.context.db = app.get(PrismaClient); -}); - -test.beforeEach(async t => { - await t.context.app.initTestingDB(); - resetAuthSessionConfig(t.context.config); - - t.context.user = await t.context.auth.signUp('u1@affine.pro', '1'); - const session = await t.context.auth.createUserSession(t.context.user.id); - t.context.sessionId = session.sessionId; -}); - -test.after.always(async t => { - await t.context.app.close(); -}); - -const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 15; -const DEFAULT_SESSION_TTR = 60 * 60 * 24 * 7; - -function resetAuthSessionConfig(config: ConfigFactory) { - config.override({ - auth: { - session: { - ttl: DEFAULT_SESSION_TTL, - ttr: DEFAULT_SESSION_TTR, - }, - }, - }); -} - -function currentJwtKey(crypto: CryptoHelper) { - return Buffer.concat([ - Buffer.from('affine:user-session-jwt:v1:'), - crypto.keyPair.sha256.privateKey, - ]); -} - -function assertSignedTtl( - t: ExecutionContext, - signed: { token: string; expiresAt: Date }, - expectedTtl: number -) { - const ttlMs = signed.expiresAt.getTime() - Date.now(); - t.true(ttlMs > (expectedTtl - 5) * 1000); - t.true(ttlMs <= (expectedTtl + 1) * 1000); - - const payload = jwt.decode(signed.token); - t.truthy(payload); - t.true(typeof payload !== 'string'); - if (!payload || typeof payload === 'string') return; - t.is(typeof payload.iat, 'number'); - t.is(typeof payload.exp, 'number'); - if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number') { - return; - } - t.is(payload.exp - payload.iat, expectedTtl); -} - -test.serial('should sign and verify a user session jwt', async t => { - const signed = t.context.jwtSession.sign( - t.context.user.id, - t.context.sessionId - ); - - const session = await t.context.jwtSession.verify(signed.token); - - t.is(session.user.id, t.context.user.id); - t.is(session.sessionId, t.context.sessionId); - t.true(signed.expiresAt.getTime() > Date.now()); -}); - -test.serial( - 'should use application session ttl for jwt expiration', - async t => { - const defaultSigned = t.context.jwtSession.sign( - t.context.user.id, - t.context.sessionId - ); - assertSignedTtl(t, defaultSigned, DEFAULT_SESSION_TTL); - - const ttl = 120; - t.context.config.override({ - auth: { - session: { - ttl, - }, - }, - }); - - const configuredSigned = t.context.jwtSession.sign( - t.context.user.id, - t.context.sessionId - ); - assertSignedTtl(t, configuredSigned, ttl); - } -); - -test.serial('should reject invalid jwt cases', async t => { - const cases: Array<{ name: string; token: string }> = [ - { - name: 'expired token', - token: jwt.sign( - { sid: t.context.sessionId, typ: 'user_session' }, - currentJwtKey(t.context.crypto), - { - algorithm: 'HS256', - audience: 'affine-client', - expiresIn: -1, - issuer: 'affine', - subject: t.context.user.id, - } - ), - }, - { - name: 'wrong signature', - token: jwt.sign( - { sid: t.context.sessionId, typ: 'user_session' }, - 'wrong-key', - { - algorithm: 'HS256', - audience: 'affine-client', - expiresIn: 60, - issuer: 'affine', - subject: t.context.user.id, - } - ), - }, - { - name: 'wrong issuer', - token: jwt.sign( - { sid: t.context.sessionId, typ: 'user_session' }, - currentJwtKey(t.context.crypto), - { - algorithm: 'HS256', - audience: 'affine-client', - expiresIn: 60, - issuer: 'other-issuer', - subject: t.context.user.id, - } - ), - }, - { - name: 'wrong audience', - token: jwt.sign( - { sid: t.context.sessionId, typ: 'user_session' }, - currentJwtKey(t.context.crypto), - { - algorithm: 'HS256', - audience: 'other-audience', - expiresIn: 60, - issuer: 'affine', - subject: t.context.user.id, - } - ), - }, - { - name: 'wrong type', - token: jwt.sign( - { sid: t.context.sessionId, typ: 'personal_access_token' }, - currentJwtKey(t.context.crypto), - { - algorithm: 'HS256', - audience: 'affine-client', - expiresIn: 60, - issuer: 'affine', - subject: t.context.user.id, - } - ), - }, - ]; - - for (const testCase of cases) { - await t.throwsAsync(() => t.context.jwtSession.verify(testCase.token), { - message: 'You must sign in first to access this resource.', - }); - } -}); - -test.serial( - 'should reject jwt when its user session is missing or expired', - async t => { - const signed = t.context.jwtSession.sign( - t.context.user.id, - t.context.sessionId - ); - - await t.context.auth.signOut(t.context.sessionId, t.context.user.id); - - await t.throwsAsync(() => t.context.jwtSession.verify(signed.token), { - message: 'You must sign in first to access this resource.', - }); - - const refreshed = await t.context.auth.createUserSession(t.context.user.id); - const expired = t.context.jwtSession.sign( - t.context.user.id, - refreshed.sessionId - ); - await t.context.db.userSession.updateMany({ - where: { - userId: t.context.user.id, - sessionId: refreshed.sessionId, - }, - data: { - expiresAt: new Date(Date.now() - 1000), - }, - }); - - await t.throwsAsync(() => t.context.jwtSession.verify(expired.token), { - message: 'You must sign in first to access this resource.', - }); - } -); diff --git a/packages/backend/server/src/__tests__/auth/service.spec.ts b/packages/backend/server/src/__tests__/auth/service.spec.ts index 6f5cd11902..82b6dcad99 100644 --- a/packages/backend/server/src/__tests__/auth/service.spec.ts +++ b/packages/backend/server/src/__tests__/auth/service.spec.ts @@ -1,7 +1,8 @@ import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; -import { CurrentUser } from '../../core/auth'; +import { AuthSessionService, CurrentUser } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; import { FeatureModule } from '../../core/features'; import { QuotaModule } from '../../core/quota'; @@ -11,6 +12,7 @@ import { createTestingModule, type TestingModule } from '../utils'; const test = ava as TestFn<{ auth: AuthService; + authSessions: AuthSessionService; models: Models; u1: CurrentUser; db: PrismaClient; @@ -24,6 +26,7 @@ test.before(async t => { }); t.context.auth = m.get(AuthService); + t.context.authSessions = m.get(AuthSessionService); t.context.models = m.get(Models); t.context.db = m.get(PrismaClient); t.context.m = m; @@ -95,6 +98,57 @@ test('should be able to change password', async t => { t.is(signedInU1.email, u1.email); }); +test('rolls back password change when session revocation fails', async t => { + const { auth, authSessions, u1 } = t.context; + const revoke = Sinon.stub(authSessions, 'revokeUserSessions').rejects( + new Error('revocation failed') + ); + + await t.throwsAsync(() => + auth.changePasswordAndRevokeSessions(u1.id, 'new password') + ); + revoke.restore(); + + await t.notThrowsAsync(() => auth.signIn(u1.email, '1')); + await t.throwsAsync(() => auth.signIn(u1.email, 'new password')); +}); + +test('rolls back email change when session revocation fails', async t => { + const { auth, authSessions, u1, models } = t.context; + const revoke = Sinon.stub(authSessions, 'revokeUserSessions').rejects( + new Error('revocation failed') + ); + + await t.throwsAsync(() => + auth.changeEmailAndRevokeSessions(u1.id, 'replacement@affine.pro') + ); + revoke.restore(); + + t.is((await models.user.get(u1.id))?.email, u1.email); +}); + +test('rolls back auth-session revocation when cookie revocation fails', async t => { + const { auth, authSessions, db, models, u1 } = t.context; + const userSession = await auth.createUserSession(u1.id); + const issued = await authSessions.create({ + userSessionId: userSession.id, + installationId: 'transactional-revocation', + platform: 'ios', + }); + const revokeCookies = Sinon.stub( + models.session, + 'deleteUserSessions' + ).rejects(new Error('cookie revocation failed')); + + await t.throwsAsync(() => auth.revokeUserSessions(u1.id)); + revokeCookies.restore(); + + const session = await db.authSession.findUniqueOrThrow({ + where: { id: issued.session.id }, + }); + t.is(session.revokedAt, null); +}); + test('should be able to change email', async t => { const { auth, u1 } = t.context; diff --git a/packages/backend/server/src/__tests__/cache.spec.ts b/packages/backend/server/src/__tests__/cache.spec.ts index 42e03c1a6b..be918dd8d7 100644 --- a/packages/backend/server/src/__tests__/cache.spec.ts +++ b/packages/backend/server/src/__tests__/cache.spec.ts @@ -67,6 +67,11 @@ test('should be able to incr/decr number cache', async t => { // increase an nonexists number t.is(await cache.increase(key('test-incr2')), 1); t.is(await cache.increase(key('test-incr2')), 2); + + const expiringKey = key('test-incr-with-ttl'); + t.is(await cache.increaseWithTtl(expiringKey, 1000), 1); + t.is(await cache.increaseWithTtl(expiringKey, 1000), 2); + t.true((await cache.ttl(expiringKey)) > 0); }); test('should be able to manipulate list cache', async t => { diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index 5497740dd7..00aa61bd99 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -93,7 +93,7 @@ test("should be able to redirect to oauth provider's login page", async t => { const res = await app .POST('/api/oauth/preflight') - .send({ provider: 'Google', client_nonce: 'test-nonce' }) + .send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' }) .expect(HttpStatus.OK); const { url } = res.body; @@ -125,7 +125,7 @@ test('should be able to redirect to oauth provider with multiple hosts', async t const res = await app .POST('/api/oauth/preflight') .set('host', 'test.affine.dev') - .send({ provider: 'Google', client_nonce: 'test-nonce' }) + .send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' }) .expect(HttpStatus.OK); const { url } = res.body; @@ -181,6 +181,17 @@ test('should be able to redirect to oauth provider with client_nonce', async t = t.truthy(state.state); }); +test('should reject unsupported oauth client identifiers', async t => { + const { app } = t.context; + const res = await app.POST('/api/oauth/preflight').send({ + provider: 'Google', + client: 'untrusted-client', + client_nonce: 'test-nonce', + }); + + t.is(res.status, 403); +}); + test('should record sign in client version from oauth preflight state', async t => { const { app, db } = t.context; @@ -197,7 +208,7 @@ test('should record sign in client version from oauth preflight state', async t const preflightRes = await app .POST('/api/oauth/preflight') .set('x-affine-version', '0.25.3') - .send({ provider: 'Google', client_nonce: 'test-nonce' }) + .send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' }) .expect(HttpStatus.OK); const redirect = new URL(preflightRes.body.url as string); @@ -238,6 +249,7 @@ test('should forbid preflight with untrusted redirect_uri', async t => { .POST('/api/oauth/preflight') .send({ provider: 'Google', + client: 'web', redirect_uri: 'https://evil.example', client_nonce: 'test-nonce', }) @@ -251,7 +263,7 @@ test('should throw if client_nonce is missing in preflight', async t => { await app .POST('/api/oauth/preflight') - .send({ provider: 'Google' }) + .send({ provider: 'Google', client: 'web' }) .expect(HttpStatus.BAD_REQUEST) .expect({ status: 400, @@ -270,7 +282,7 @@ test('should throw if provider is invalid', async t => { await app .POST('/api/oauth/preflight') - .send({ provider: 'Invalid', client_nonce: 'test-nonce' }) + .send({ provider: 'Invalid', client: 'web', client_nonce: 'test-nonce' }) .expect(HttpStatus.BAD_REQUEST) .expect({ status: 400, @@ -575,12 +587,17 @@ test('should be able to sign up with oauth', async t => { t.truthy(res.body.exchangeCode); const tokenRes = await app - .POST('/api/auth/native/exchange') + .POST('/api/auth/session/exchange') .set('x-affine-client-kind', 'native') - .send({ code: res.body.exchangeCode }) + .send({ + code: res.body.exchangeCode, + installationId: '00000000-0000-4000-8000-000000000005', + platform: 'electron', + }) .expect(201); - t.truthy(tokenRes.body.token); - t.truthy(tokenRes.body.expiresAt); + t.truthy(tokenRes.body.accessToken); + t.is(tokenRes.body.expiresIn, 15 * 60); + t.truthy(tokenRes.body.refreshToken); const setCookies = res.get('Set-Cookie') ?? []; for (const name of [ AuthService.sessionCookieName, @@ -598,7 +615,7 @@ test('should be able to sign up with oauth', async t => { const sessionUserRes = await app .GET('/api/auth/session') - .set('Authorization', `Bearer ${tokenRes.body.token}`) + .set('Authorization', `Bearer ${tokenRes.body.accessToken}`) .expect(200); const sessionUser = sessionUserRes.body.user; diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index 1e0f523059..51def52036 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -148,25 +148,36 @@ function expectNoEvent( } async function login(app: TestingApp) { - const user = await app.createUser(); - const cookieRes = await app - .POST('/api/auth/sign-in') - .send({ email: user.email, password: user.password }) - .expect(200); + const { user, cookieHeader } = await loginWithCookie(app); const nativeRes = await app .POST('/api/auth/sign-in') .set('x-affine-client-kind', 'native') .send({ email: user.email, password: user.password }) .expect(200); const tokenRes = await app - .POST('/api/auth/native/exchange') + .POST('/api/auth/session/exchange') .set('x-affine-client-kind', 'native') - .send({ code: nativeRes.body.exchangeCode }) + .send({ + code: nativeRes.body.exchangeCode, + installationId: '00000000-0000-4000-8000-000000000005', + platform: 'electron', + }) .expect(201); + return { user, cookieHeader, token: tokenRes.body.accessToken as string }; +} + +async function loginWithCookie(app: TestingApp) { + const user = await app.createUser(); + const cookieRes = await app + .POST('/api/auth/sign-in') + .set('x-affine-version', '0.26.7') + .send({ email: user.email, password: user.password }) + .expect(200); + const cookies = cookieRes.get('Set-Cookie') ?? []; const cookieHeader = cookies.map(c => c.split(';')[0]).join('; '); - return { user, cookieHeader, token: tokenRes.body.token as string }; + return { user, cookieHeader }; } function createYjsUpdateBase64() { @@ -366,7 +377,7 @@ test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', asyn }); test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', async t => { - const { user, cookieHeader } = await login(app); + const { user, cookieHeader } = await loginWithCookie(app); const spaceId = user.id; const update = createYjsUpdateBase64(); @@ -381,7 +392,7 @@ test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', as await emitWithAck<{ clientId: string; success: boolean }>( receiver, 'space:join', - { spaceType: 'userspace', spaceId, clientVersion: '0.26.0' } + { spaceType: 'userspace', spaceId, clientVersion: '0.26.7' } ) ); t.true(receiverJoin.success); diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index 8a87b3a096..15adec7948 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -21,7 +21,7 @@ import { JobQueue, } from '../../base'; import { SocketIoAdapter } from '../../base/websocket'; -import { AuthService } from '../../core/auth'; +import { AuthService, AuthSigningKeyRing } from '../../core/auth'; import { Mailer } from '../../core/mail'; import { UserModel } from '../../models'; import { @@ -110,6 +110,7 @@ export class TestingApp extends ApplyType() { async initTestingDB() { await initTestingDB(this); + await this.get(AuthSigningKeyRing).onConfigInit(); this.clearAuth(); } diff --git a/packages/backend/server/src/base/cache/provider.ts b/packages/backend/server/src/base/cache/provider.ts index 4953d43167..c499a2c313 100644 --- a/packages/backend/server/src/base/cache/provider.ts +++ b/packages/backend/server/src/base/cache/provider.ts @@ -15,6 +15,14 @@ end return value `; +const INCREASE_WITH_TTL_LUA = ` +local value = redis.call("INCRBY", KEYS[1], ARGV[1]) +if value == tonumber(ARGV[1]) or redis.call("PTTL", KEYS[1]) == -1 then + redis.call("PEXPIRE", KEYS[1], ARGV[2]) +end +return value +`; + export function isValidCacheTtl(ttl: unknown): ttl is number { return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0; } @@ -60,6 +68,18 @@ export class CacheProvider { return this.redis.incrby(key, count).catch(() => 0); } + async increaseWithTtl( + key: string, + ttl: number, + count: number = 1 + ): Promise { + if (!isValidCacheTtl(ttl)) return 0; + return this.redis + .eval(INCREASE_WITH_TTL_LUA, 1, key, count, ttl) + .then(value => Number(value)) + .catch(() => 0); + } + async decrease(key: string, count: number = 1): Promise { return this.redis.decrby(key, count).catch(() => 0); } diff --git a/packages/backend/server/src/base/cors.ts b/packages/backend/server/src/base/cors.ts index 7b8231f858..76939e02a4 100644 --- a/packages/backend/server/src/base/cors.ts +++ b/packages/backend/server/src/base/cors.ts @@ -29,10 +29,12 @@ export const CORS_ALLOWED_HEADERS = [ 'authorization', 'content-type', 'x-affine-version', + 'x-affine-client-kind', 'x-operation-name', 'x-request-id', 'x-captcha-token', 'x-captcha-challenge', + 'x-captcha-provider', 'x-affine-csrf-token', 'x-requested-with', 'range', diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 27b9b77619..4a8622a0ff 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -422,6 +422,34 @@ export const USER_FRIENDLY_ERRORS = { type: 'authentication_required', message: 'You must sign in first to access this resource.', }, + access_token_expired: { + type: 'authentication_required', + message: 'The access token has expired.', + }, + access_token_invalid: { + type: 'authentication_required', + message: 'The access token is invalid.', + }, + auth_session_expired: { + type: 'authentication_required', + message: 'The auth session has expired.', + }, + auth_session_revoked: { + type: 'authentication_required', + message: 'The auth session has been revoked.', + }, + refresh_token_invalid: { + type: 'authentication_required', + message: 'The refresh token is invalid.', + }, + refresh_token_reused: { + type: 'authentication_required', + message: 'The refresh token has already been used.', + }, + auth_session_temporarily_unavailable: { + type: 'network_error', + message: 'Auth session service is temporarily unavailable.', + }, action_forbidden: { type: 'action_forbidden', message: 'You are not allowed to perform this action.', diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 4700509f17..1308e7b552 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -274,6 +274,48 @@ export class AuthenticationRequired extends UserFriendlyError { } } +export class AccessTokenExpired extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'access_token_expired', message); + } +} + +export class AccessTokenInvalid extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'access_token_invalid', message); + } +} + +export class AuthSessionExpired extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'auth_session_expired', message); + } +} + +export class AuthSessionRevoked extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'auth_session_revoked', message); + } +} + +export class RefreshTokenInvalid extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'refresh_token_invalid', message); + } +} + +export class RefreshTokenReused extends UserFriendlyError { + constructor(message?: string) { + super('authentication_required', 'refresh_token_reused', message); + } +} + +export class AuthSessionTemporarilyUnavailable extends UserFriendlyError { + constructor(message?: string) { + super('network_error', 'auth_session_temporarily_unavailable', message); + } +} + export class ActionForbidden extends UserFriendlyError { constructor(message?: string) { super('action_forbidden', 'action_forbidden', message); @@ -1200,6 +1242,13 @@ export enum ErrorNames { INVALID_EMAIL_TOKEN, LINK_EXPIRED, AUTHENTICATION_REQUIRED, + ACCESS_TOKEN_EXPIRED, + ACCESS_TOKEN_INVALID, + AUTH_SESSION_EXPIRED, + AUTH_SESSION_REVOKED, + REFRESH_TOKEN_INVALID, + REFRESH_TOKEN_REUSED, + AUTH_SESSION_TEMPORARILY_UNAVAILABLE, ACTION_FORBIDDEN, ACCESS_DENIED, EMAIL_VERIFICATION_REQUIRED, diff --git a/packages/backend/server/src/core/auth/access-token.ts b/packages/backend/server/src/core/auth/access-token.ts new file mode 100644 index 0000000000..809e7ff5a0 --- /dev/null +++ b/packages/backend/server/src/core/auth/access-token.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; + +import { AuthSessionTemporarilyUnavailable, Config } from '../../base'; +import { Models } from '../../models'; +import { + authSessionAccessTokenKeyId, + signAuthSessionAccessToken, + verifyAuthSessionAccessToken, +} from '../../native'; +import { sessionUser } from './service'; +import type { AuthSessionPrincipal, CurrentUser } from './session'; +import { AuthSigningKeyRing } from './signing-key'; + +export type SessionAccessTokenErrorCode = + | 'ACCESS_TOKEN_EXPIRED' + | 'ACCESS_TOKEN_INVALID' + | 'AUTH_SESSION_EXPIRED' + | 'AUTH_SESSION_REVOKED'; + +export class SessionAccessTokenError extends Error { + constructor(readonly code: SessionAccessTokenErrorCode) { + super(code); + } +} + +export interface SignedAccessToken { + token: string; + expiresAt: Date; +} + +const SIGNING_KEY_RETRY_LIMIT = 3; + +@Injectable() +export class AccessTokenService { + constructor( + private readonly models: Models, + private readonly config: Config, + private readonly keys: AuthSigningKeyRing + ) {} + + async sign( + userId: string, + authSessionId: string + ): Promise { + const ttl = this.config.auth.token.accessTokenTtl; + for (let attempt = 0; attempt < SIGNING_KEY_RETRY_LIMIT; attempt++) { + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAtSeconds = issuedAt + ttl; + const expiresAt = new Date(expiresAtSeconds * 1000); + const key = await this.keys.active(); + const token = signAuthSessionAccessToken( + userId, + authSessionId, + key.id, + key.secret, + issuedAt, + expiresAtSeconds + ); + if ((await this.keys.active()).id === key.id) { + return { token, expiresAt }; + } + } + throw new AuthSessionTemporarilyUnavailable(); + } + + async verify(token: string): Promise { + const keyId = authSessionAccessTokenKeyId(token); + if (!keyId) { + throw new SessionAccessTokenError('ACCESS_TOKEN_INVALID'); + } + const key = await this.keys.verify(keyId); + if (!key) throw new SessionAccessTokenError('ACCESS_TOKEN_INVALID'); + const verified = verifyAuthSessionAccessToken( + token, + keyId, + key.secret, + Math.floor(Date.now() / 1000) + ); + if (verified.status !== 'valid') { + throw new SessionAccessTokenError( + verified.status === 'expired' + ? 'ACCESS_TOKEN_EXPIRED' + : 'ACCESS_TOKEN_INVALID' + ); + } + const { authSessionId, userId } = verified; + if (!authSessionId || !userId) { + throw new SessionAccessTokenError('ACCESS_TOKEN_INVALID'); + } + const authSession = await this.models.authSession.get(authSessionId); + if (!authSession || authSession.userSession.userId !== userId) { + throw new SessionAccessTokenError('ACCESS_TOKEN_INVALID'); + } + if (authSession.revokedAt) { + throw new SessionAccessTokenError('AUTH_SESSION_REVOKED'); + } + const now = new Date(); + if ( + authSession.idleExpiresAt <= now || + authSession.absoluteExpiresAt <= now || + (authSession.userSession.expiresAt && + authSession.userSession.expiresAt <= now) + ) { + throw new SessionAccessTokenError('AUTH_SESSION_EXPIRED'); + } + const user = await this.models.user.get(userId); + if (!user || user.disabled) { + throw new SessionAccessTokenError('AUTH_SESSION_REVOKED'); + } + return { + ...authSession.userSession, + authSessionId: authSession.id, + authenticatedAt: authSession.createdAt, + user: sessionUser(user) as CurrentUser, + }; + } +} + +export function isLikelyJwt(token: string) { + return token.split('.').length === 3; +} diff --git a/packages/backend/server/src/core/auth/auth-session.ts b/packages/backend/server/src/core/auth/auth-session.ts new file mode 100644 index 0000000000..81a92ac8b4 --- /dev/null +++ b/packages/backend/server/src/core/auth/auth-session.ts @@ -0,0 +1,392 @@ +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; + +import { + Cache, + Config, + CryptoHelper, + EventBus, + metrics, + OnEvent, +} from '../../base'; +import { Models } from '../../models'; +import { + createAuthSessionRefreshToken, + parseAuthSessionRefreshToken, +} from '../../native'; + +export const AuthSessionErrorCode = { + invalid: 'REFRESH_TOKEN_INVALID', + expired: 'AUTH_SESSION_EXPIRED', + revoked: 'AUTH_SESSION_REVOKED', + reused: 'REFRESH_TOKEN_REUSED', + temporarilyUnavailable: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', +} as const; + +declare global { + interface Events { + 'auth.session.created': { + authSessionId: string; + platform: string; + }; + 'auth.session.refreshed': { + authSessionId: string; + }; + 'auth.session.revoked': { + authSessionId: string; + reason: string; + }; + 'auth.session.refresh_reused': { + authSessionId?: string; + }; + 'auth.security.detected': { + type: 'new_device_login' | 'refresh_replay' | 'sessions_revoked'; + userId: string; + authSessionId?: string; + reason?: string; + notification: 'policy_pending' | 'none'; + }; + 'auth.sessions.revoke_requested': { + userId: string; + reason: string; + }; + } +} + +export interface CreateAuthSessionInput { + userSessionId: string; + installationId: string; + platform: string; + deviceName?: string; + appVersion?: string; +} + +export type AuthRefreshResult = + | { + status: 'rotated'; + refreshToken: string; + refreshExpiresAt: Date; + authSessionId: string; + userSessionId: string; + } + | { + status: 'reused'; + code: (typeof AuthSessionErrorCode)['reused']; + authSessionId: string; + platform: string; + } + | { status: 'invalid'; code: (typeof AuthSessionErrorCode)['invalid'] } + | { status: 'expired'; code: (typeof AuthSessionErrorCode)['expired'] } + | { status: 'revoked'; code: (typeof AuthSessionErrorCode)['revoked'] } + | { + status: 'temporarily_unavailable'; + code: (typeof AuthSessionErrorCode)['temporarilyUnavailable']; + }; + +@Injectable() +export class AuthSessionService { + constructor( + private readonly config: Config, + private readonly crypto: CryptoHelper, + private readonly cache: Cache, + private readonly event: EventBus, + private readonly models: Models + ) {} + + get accessTokenTtl() { + return this.config.auth.token.accessTokenTtl; + } + + @Transactional() + async create(input: CreateAuthSessionInput) { + const isNewDevice = !(await this.models.authSession.hasUserInstallation( + input.userSessionId, + input.installationId + )); + const now = new Date(); + const absoluteExpiresAt = new Date( + now.getTime() + this.config.auth.token.refreshAbsoluteTtl * 1000 + ); + const idleExpiresAt = this.idleExpiresAt(now, absoluteExpiresAt); + const refresh = this.createRefreshToken(idleExpiresAt); + const session = await this.models.authSession.create({ + ...input, + idleExpiresAt, + absoluteExpiresAt, + refreshToken: refresh.persisted, + }); + metrics.auth.counter('auth_session_created').add(1, { + platform: input.platform, + }); + this.event.emit('auth.session.created', { + authSessionId: session.id, + platform: input.platform, + }); + const created = isNewDevice + ? await this.models.authSession.get(session.id) + : null; + if (created) { + this.event.emit('auth.security.detected', { + type: 'new_device_login', + userId: created.userSession.userId, + authSessionId: session.id, + notification: 'policy_pending', + }); + } + + return { + session, + refreshToken: refresh.token, + refreshExpiresAt: idleExpiresAt, + }; + } + + async refresh( + refreshToken: string, + appVersion?: string + ): Promise { + const parsed = this.parseRefreshToken(refreshToken); + if (!parsed) { + return { status: 'invalid', code: AuthSessionErrorCode.invalid }; + } + + const now = new Date(); + const refresh = await this.preparedRefresh( + parsed.id, + new Date(now.getTime() + this.config.auth.token.refreshIdleTtl * 1000) + ); + if (!refresh) { + metrics.auth.counter('auth_refresh').add(1, { + result: 'temporarily_unavailable', + platform: 'unknown', + }); + return { + status: 'temporarily_unavailable', + code: AuthSessionErrorCode.temporarilyUnavailable, + }; + } + const result = await this.models.authSession.rotate({ + id: parsed.id, + secretHash: parsed.secretHash, + now, + idleExpiresAt: refresh.persisted.expiresAt, + graceMs: this.config.auth.token.refreshGracePeriod * 1000, + appVersion, + next: refresh.persisted, + }); + if (result.status === 'reused') { + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: result.platform, + }); + this.event.emit('auth.session.refresh_reused', { + authSessionId: result.authSessionId, + }); + const reused = await this.models.authSession.get(result.authSessionId); + if (reused) { + this.event.emit('auth.security.detected', { + type: 'refresh_replay', + userId: reused.userSession.userId, + authSessionId: result.authSessionId, + notification: 'policy_pending', + }); + } + return { + ...result, + code: AuthSessionErrorCode.reused, + }; + } + if (result.status === 'invalid') { + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: 'unknown', + }); + return { ...result, code: AuthSessionErrorCode.invalid }; + } + if (result.status === 'expired') { + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: 'unknown', + }); + return { ...result, code: AuthSessionErrorCode.expired }; + } + if (result.status === 'revoked') { + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: 'unknown', + }); + return { ...result, code: AuthSessionErrorCode.revoked }; + } + if (result.status === 'temporarily_unavailable') { + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: 'unknown', + }); + return { + status: result.status, + code: AuthSessionErrorCode.temporarilyUnavailable, + }; + } + + const session = await this.models.authSession.get(result.authSessionId); + if (!session) { + return { status: 'revoked', code: AuthSessionErrorCode.revoked }; + } + metrics.auth.counter('auth_refresh').add(1, { + result: result.status, + platform: result.platform, + }); + if (result.status === 'grace') { + metrics.auth.counter('auth_refresh_grace').add(1, { + platform: result.platform, + }); + } + this.event.emit('auth.session.refreshed', { + authSessionId: result.authSessionId, + }); + + return { + status: 'rotated', + authSessionId: result.authSessionId, + userSessionId: result.userSessionId, + refreshToken: refresh.token, + refreshExpiresAt: + refresh.persisted.expiresAt < session.absoluteExpiresAt + ? refresh.persisted.expiresAt + : session.absoluteExpiresAt, + }; + } + + async revoke(id: string, reason: string, userId?: string) { + const revoked = await this.models.authSession.revoke(id, reason, userId); + if (revoked) { + metrics.auth.counter('auth_session_revoked').add(1, { reason }); + this.event.emit('auth.session.revoked', { + authSessionId: id, + reason, + }); + } + return revoked; + } + + async revokeUserSessions(userId: string, reason: string) { + const { count } = await this.models.authSession.revokeUserSessions( + userId, + reason + ); + if (count) { + metrics.auth.counter('auth_session_revoked').add(count, { reason }); + this.event.emit('auth.security.detected', { + type: 'sessions_revoked', + userId, + reason, + notification: 'none', + }); + } + return count; + } + + @OnEvent('user.preDelete') + async onUserPreDelete({ id }: Events['user.preDelete']) { + await this.revokeUserSessions(id, 'user_deleted_or_disabled'); + } + + async revokeWithRefreshToken(refreshToken: string) { + const parsed = this.parseRefreshToken(refreshToken); + if (!parsed) return; + const sessionId = await this.models.authSession.findByRefreshToken( + parsed.id, + parsed.secretHash + ); + if (sessionId) await this.revoke(sessionId, 'refresh_token_revoke'); + } + + async list(userId: string) { + return (await this.models.authSession.list(userId)).map(session => ({ + id: session.id, + installationId: session.installationId, + platform: session.platform, + deviceName: session.deviceName, + appVersion: session.appVersion, + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + idleExpiresAt: session.idleExpiresAt, + absoluteExpiresAt: session.absoluteExpiresAt, + revokedAt: session.revokedAt, + revokeReason: session.revokeReason, + })); + } + + async get(id: string) { + return await this.models.authSession.get(id); + } + + async cleanup() { + const before = new Date( + Date.now() - this.config.auth.token.refreshRetention * 1000 + ); + let total = 0; + for (;;) { + const count = await this.models.authSession.deleteExpiredRefreshTokens( + before, + 1000 + ); + total += count; + if (count < 1000) break; + } + for (;;) { + const count = await this.models.authSession.deleteExpiredSessions( + before, + 1000 + ); + total += count; + if (count < 1000) return total; + } + } + + private createRefreshToken(expiresAt: Date) { + const created = createAuthSessionRefreshToken(); + return { + token: created.token, + persisted: { + id: created.id, + secretHash: created.secretHash, + expiresAt, + }, + }; + } + + private async preparedRefresh(sourceTokenId: string, expiresAt: Date) { + const key = `auth:session-refresh:${sourceTokenId}`; + const created = this.createRefreshToken(expiresAt); + await this.cache.setnx(key, this.crypto.encrypt(JSON.stringify(created)), { + ttl: this.config.auth.token.refreshGracePeriod * 1000 + 5000, + }); + const encrypted = await this.cache.get(key); + if (!encrypted) return null; + const cached = JSON.parse(this.crypto.decrypt(encrypted)) as { + token: string; + persisted: { id: string; secretHash: string; expiresAt: string }; + }; + return { + token: cached.token, + persisted: { + ...cached.persisted, + expiresAt: new Date(cached.persisted.expiresAt), + }, + }; + } + + private parseRefreshToken(token: string) { + return parseAuthSessionRefreshToken(token); + } + + private idleExpiresAt(now: Date, absoluteExpiresAt: Date) { + const idleExpiresAt = new Date( + now.getTime() + this.config.auth.token.refreshIdleTtl * 1000 + ); + return idleExpiresAt < absoluteExpiresAt + ? idleExpiresAt + : absoluteExpiresAt; + } +} diff --git a/packages/backend/server/src/core/auth/challenge-store.ts b/packages/backend/server/src/core/auth/challenge-store.ts index 5cf65f9106..91935471cb 100644 --- a/packages/backend/server/src/core/auth/challenge-store.ts +++ b/packages/backend/server/src/core/auth/challenge-store.ts @@ -8,7 +8,7 @@ import { isValidCacheTtl } from '../../base/cache/provider'; export type AuthChallengePurpose = | 'oauth_state' | 'open_app_sign_in' - | 'native_session_exchange' + | 'auth_session_exchange' | 'captcha' | 'passkey_registration' | 'passkey_authentication'; diff --git a/packages/backend/server/src/core/auth/config.ts b/packages/backend/server/src/core/auth/config.ts index b3181903ee..be4e67661e 100644 --- a/packages/backend/server/src/core/auth/config.ts +++ b/packages/backend/server/src/core/auth/config.ts @@ -7,6 +7,13 @@ export interface AuthConfig { ttl: number; ttr: number; }; + token: { + accessTokenTtl: number; + refreshIdleTtl: number; + refreshAbsoluteTtl: number; + refreshGracePeriod: number; + refreshRetention: number; + }; allowSignup: boolean; allowSignupForOauth: boolean; requireEmailDomainVerification: boolean; @@ -95,4 +102,45 @@ defineModuleConfig('auth', { desc: 'Application auth time to refresh in seconds.', default: 60 * 60 * 24 * 7, // 7 days }, + 'token.accessTokenTtl': { + desc: 'Access JWT expiration time in seconds.', + default: 15 * 60, + shape: z + .number() + .int() + .min(60) + .max(60 * 60), + }, + 'token.refreshIdleTtl': { + desc: 'Auth refresh session inactivity expiration in seconds.', + default: 60 * 60 * 24 * 30, + shape: z + .number() + .int() + .min(60 * 60) + .max(60 * 60 * 24 * 365), + }, + 'token.refreshAbsoluteTtl': { + desc: 'Auth refresh session absolute expiration in seconds.', + default: 60 * 60 * 24 * 180, + shape: z + .number() + .int() + .min(60 * 60) + .max(60 * 60 * 24 * 730), + }, + 'token.refreshGracePeriod': { + desc: 'One-use refresh rotation concurrency grace period in seconds.', + default: 30, + shape: z.number().int().min(0).max(60), + }, + 'token.refreshRetention': { + desc: 'Retention for expired auth refresh generations in seconds.', + default: 60 * 60 * 24 * 30, + shape: z + .number() + .int() + .min(60 * 60) + .max(60 * 60 * 24 * 365), + }, }); diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 3dbcb8fd8a..4c9aac4ce6 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -3,9 +3,11 @@ import { setServers } from 'node:dns/promises'; import { Body, Controller, + Delete, Get, Header, HttpStatus, + Param, Post, Query, Req, @@ -17,6 +19,7 @@ import { ActionForbidden, Config, EmailTokenNotFound, + getClientVersionFromRequest, getRequestCookie, InvalidAuthState, InvalidEmail, @@ -27,13 +30,24 @@ import { import { Models } from '../../models'; import { validators } from '../utils/validators'; import { getAbuseRequestSource } from '../workspaces/abuse'; +import { AuthSessionService } from './auth-session'; import { Public } from './guard'; +import { + AuthPreflightBodySchema, + AuthSessionExchangeBodySchema, + AuthSessionRefreshBodySchema, + isNativeClientRequest, + MagicLinkBodySchema, + OpenAppSignInBodySchema, + SessionIdSchema, + SignInBodySchema, +} from './input'; 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 { AuthSessionPrincipal, CurrentUser, Session } from './session'; +import { SessionExchangeService } from './session-exchange'; import { SessionIssuer } from './session-issuer'; interface PreflightResponse { @@ -46,27 +60,6 @@ interface PreflightResponse { }; } -interface SignInCredential { - email: string; - password?: string; - callbackUrl?: string; - client_nonce?: string; -} - -interface MagicLinkCredential { - email: string; - token: string; - client_nonce?: string; -} - -interface OpenAppSignInCredential { - code: string; -} - -interface NativeSessionExchangeCredential { - code: string; -} - type SignInResponse = CurrentUser & { exchangeCode?: string; }; @@ -81,6 +74,7 @@ export class AuthController { private readonly openApp: OpenAppAuthService, private readonly authMethods: AuthMethodsService, private readonly sessionExchange: SessionExchangeService, + private readonly authSessions: AuthSessionService, private readonly models: Models, private readonly config: Config ) { @@ -97,15 +91,14 @@ export class AuthController { @Public() @UseNamedGuard('version') @Post('/preflight') - async preflight( - @Body() params?: { email: string } - ): Promise { - if (!params?.email) { + async preflight(@Body() body?: unknown): Promise { + const input = AuthPreflightBodySchema.safeParse(body); + if (!input.success) { throw new InvalidEmail({ email: 'not provided' }); } - validators.assertValidEmail(params.email); + validators.assertValidEmail(input.data.email); - return this.authMethods.loginPreflight(params.email); + return this.authMethods.loginPreflight(input.data.email); } @UseNamedGuard('version') @@ -121,8 +114,9 @@ export class AuthController { async signIn( @Req() req: Request, @Res() res: Response, - @Body() credential: SignInCredential + @Body() body?: unknown ) { + const credential = SignInBodySchema.parse(body); validators.assertValidEmail(credential.email); const canSignIn = await this.auth.canSignIn(credential.email); if (!canSignIn) { @@ -183,7 +177,7 @@ export class AuthController { async signOut( @Req() req: Request, @Res() res: Response, - @Session() session: Session | undefined, + @Session() session: Session | AuthSessionPrincipal | undefined, @Query('user_id') userId: string | undefined ) { if (!session) { @@ -192,7 +186,15 @@ export class AuthController { } if (req.authType === 'jwt') { - await this.auth.signOut(session.sessionId, session.user.id); + const authSessionId = (session as Partial) + .authSessionId; + if (authSessionId) { + await this.authSessions.revoke( + authSessionId, + 'current_device_sign_out', + session.user.id + ); + } res.status(HttpStatus.OK).send({}); return; } @@ -224,23 +226,105 @@ export class AuthController { async openAppSignIn( @Req() req: Request, @Res() res: Response, - @Body() credential: OpenAppSignInCredential + @Body() body?: unknown ) { - if (!credential?.code) throw new InvalidAuthState(); - const identity = await this.openApp.verifySignInCode(credential.code); + const credential = OpenAppSignInBodySchema.safeParse(body); + if (!credential.success) throw new InvalidAuthState(); + const identity = await this.openApp.verifySignInCode(credential.data.code); const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); res.send({ id: identity.userId, exchangeCode }); } @Public() @UseNamedGuard('version') - @Post('/native/exchange') - async exchangeSession( - @Req() req: Request, - @Body() credential: NativeSessionExchangeCredential + @Post('/session/exchange') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async exchangeSession(@Req() req: Request, @Body() body?: unknown) { + const input = AuthSessionExchangeBodySchema.parse(body); + return await this.sessionExchange.exchange(req, input.code, { + installationId: input.installationId, + platform: input.platform, + deviceName: input.deviceName, + appVersion: getClientVersionFromRequest(req) ?? undefined, + }); + } + + @Public() + @UseNamedGuard('version') + @Throttle('default', { limit: 120, ttl: 60_000 }) + @Post('/session/refresh') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async refreshAuthSession(@Req() req: Request, @Body() body?: unknown) { + const input = AuthSessionRefreshBodySchema.parse(body); + return await this.sessionExchange.refresh( + req, + input.refreshToken, + getClientVersionFromRequest(req) ?? undefined + ); + } + + @Public() + @UseNamedGuard('version') + @Post('/session/revoke') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async revokeCurrentAuthSession(@Req() req: Request, @Body() body?: unknown) { + if (!isNativeClientRequest(req)) { + throw new ActionForbidden(); + } + const input = AuthSessionRefreshBodySchema.parse(body); + await this.authSessions.revokeWithRefreshToken(input.refreshToken); + return {}; + } + + @Get('/sessions') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async listAuthSessions( + @CurrentUser() user: CurrentUser, + @Session() session: Session | AuthSessionPrincipal | undefined ) { - if (!credential?.code) throw new InvalidAuthState(); - return await this.sessionExchange.exchange(req, credential.code); + const currentId = (session as Partial | undefined) + ?.authSessionId; + return (await this.authSessions.list(user.id)).map(item => ({ + ...item, + current: item.id === currentId, + })); + } + + @Post('/sessions/revoke-all') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async revokeAllAuthSessions( + @Req() req: Request, + @CurrentUser() user: CurrentUser, + @Session() session: Session | AuthSessionPrincipal | undefined + ) { + this.assertSessionMutationAuthorized(req, session); + await this.auth.revokeUserSessions(user.id); + return {}; + } + + @Delete('/sessions/:id') + @Header('Cache-Control', 'no-store') + @Header('Pragma', 'no-cache') + async revokeAuthSession( + @Req() req: Request, + @CurrentUser() user: CurrentUser, + @Session() session: Session | AuthSessionPrincipal | undefined, + @Param('id') authSessionId: string + ) { + const parsedSessionId = SessionIdSchema.safeParse(authSessionId); + if (!parsedSessionId.success) throw new InvalidAuthState(); + this.assertSessionMutationAuthorized(req, session); + await this.authSessions.revoke( + parsedSessionId.data, + 'user_action', + user.id + ); + return {}; } @Public() @@ -249,10 +333,12 @@ export class AuthController { async magicLinkSignIn( @Req() req: Request, @Res() res: Response, - @Body() - { email, token: otp, client_nonce: clientNonce }: MagicLinkCredential + @Body() body?: unknown ) { - if (!otp || !email) throw new EmailTokenNotFound(); + const credential = MagicLinkBodySchema.safeParse(body); + if (!credential.success) throw new EmailTokenNotFound(); + const { email, token: otp, client_nonce: clientNonce } = credential.data; + if (!email) throw new EmailTokenNotFound(); validators.assertValidEmail(email); const identity = await this.magicLink.verify(email, otp, clientNonce); const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); @@ -267,4 +353,19 @@ export class AuthController { async currentSessionUser(@CurrentUser() user?: CurrentUser) { return { user }; } + + private assertSessionMutationAuthorized( + req: Request, + session: Session | AuthSessionPrincipal | undefined + ) { + if (req.authType === 'jwt') { + const principal = session as Partial | undefined; + if (principal?.authSessionId) return; + } else if (req.authType === 'session') { + const csrfCookie = getRequestCookie(req, AuthService.csrfCookieName); + const csrfHeader = req.get('x-affine-csrf-token'); + if (csrfHeader && csrfCookie && csrfCookie === csrfHeader) return; + } + throw new ActionForbidden(); + } } diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 1ab9025b44..d294a7dda3 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -24,13 +24,15 @@ import { } from '../../base'; import { WEBSOCKET_OPTIONS } from '../../base/websocket'; import { - extractTokenFromHeader, - getSessionOptionsFromRequest, - SessionIdSchema, -} from './input'; -import { isLikelyJwt, JwtSessionService } from './jwt-session'; + AccessTokenService, + isLikelyJwt, + SessionAccessTokenError, +} from './access-token'; +import { AuthSessionService } from './auth-session'; +import { extractTokenFromHeader } from './input'; import { AuthService } from './service'; -import { Session, TokenSession } from './session'; +import { AuthSessionPrincipal, Session, TokenSession } from './session'; +import { AuthSessionHttpError } from './session-exchange'; const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public'); const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal'); @@ -40,13 +42,13 @@ 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 accessTokens!: AccessTokenService; + private authSessions!: AuthSessionService; private readonly cachedVersionRange = new Map(); private static readonly HARD_REQUIRED_VERSION = '>=0.25.0'; private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)'; @@ -61,7 +63,8 @@ export class AuthGuard implements CanActivate, OnModuleInit { onModuleInit() { this.auth = this.ref.get(AuthService, { strict: false }); - this.jwtSession = this.ref.get(JwtSessionService, { strict: false }); + this.accessTokens = this.ref.get(AccessTokenService, { strict: false }); + this.authSessions = this.ref.get(AuthSessionService, { strict: false }); } async canActivate(context: ExecutionContext) { @@ -138,29 +141,19 @@ export class AuthGuard implements CanActivate, OnModuleInit { 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; + if (err instanceof SessionAccessTokenError) { + throw new AuthSessionHttpError(err.code); + } + throw err; } } - 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 }; - } + if (bearer) { const token = await this.signInWithAccessToken(req); return token ? { type: 'access_token', token } : null; } @@ -176,7 +169,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { isPublic = false ): Promise { if (req.session && req.authType === 'jwt') return req.session; - const session = await this.jwtSession.verify(token); + const session = await this.accessTokens.verify(token); const versionAllowed = await this.checkUserSessionClientVersion( req, session, @@ -189,39 +182,6 @@ export class AuthGuard implements CanActivate, OnModuleInit { return req.session; } - async signInWithSessionId( - req: Request, - sessionId: string, - res?: Response, - isPublic = false - ): Promise { - 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( req: Request, res?: Response, @@ -285,12 +245,22 @@ export class AuthGuard implements CanActivate, OnModuleInit { return true; } - await this.auth.signOut(session.sessionId); - if (res) { + const authSessionId = (session as Partial) + .authSessionId; + if (authSessionId) { + await this.authSessions.revoke( + authSessionId, + 'unsupported_client_version', + session.user.id + ); + } else { + await this.auth.signOut(session.sessionId); + } + if (res && !authSessionId) { await this.auth.refreshCookies(res, session.sessionId); } - if (isPublic) { + if (isPublic && !authSessionId) { return false; } diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index e6bba8a0a6..bd210b6576 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -7,18 +7,21 @@ import { FeatureModule } from '../features'; import { MailModule } from '../mail'; import { QuotaModule } from '../quota'; import { UserModule } from '../user'; +import { AccessTokenService } from './access-token'; +import { AuthSessionService } from './auth-session'; 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 { SessionExchangeService } from './session-exchange'; import { SessionIssuer } from './session-issuer'; +import { AuthSigningKeyRing } from './signing-key'; +import { AuthSigningKeyResolver } from './signing-key-resolver'; @Module({ imports: [ @@ -32,26 +35,31 @@ import { SessionIssuer } from './session-issuer'; AuthService, AuthResolver, AuthGuard, - JwtSessionService, + AccessTokenService, SessionIssuer, AuthChallengeStore, MagicLinkAuthService, OpenAppAuthService, AuthMethodsService, SessionExchangeService, + AuthSessionService, + AuthSigningKeyRing, + AuthSigningKeyResolver, AuthCronJob, AuthWebsocketOptionsProvider, ], exports: [ AuthService, AuthGuard, - JwtSessionService, + AccessTokenService, SessionIssuer, AuthChallengeStore, MagicLinkAuthService, OpenAppAuthService, AuthMethodsService, SessionExchangeService, + AuthSessionService, + AuthSigningKeyRing, AuthWebsocketOptionsProvider, ], controllers: [AuthController], @@ -65,7 +73,9 @@ export * from './input'; export { MagicLinkAuthService } from './magic-link'; export * from './methods'; export { SessionExchangeService }; +export { AuthSessionService } from './auth-session'; export { OpenAppAuthService } from './open-app'; export { ClientTokenType } from './resolver'; -export { AuthService, JwtSessionService, SessionIssuer }; +export { AccessTokenService, AuthService, SessionIssuer }; export * from './session'; +export { AuthSigningKeyRing } from './signing-key'; diff --git a/packages/backend/server/src/core/auth/input.ts b/packages/backend/server/src/core/auth/input.ts index d0108926de..b64f77a6ae 100644 --- a/packages/backend/server/src/core/auth/input.ts +++ b/packages/backend/server/src/core/auth/input.ts @@ -34,35 +34,53 @@ export const UserIdSchema = z.union([ 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), -}); +const EmailSchema = z.string().max(320); +const ClientNonceSchema = z.string().min(1).max(512); +const ChallengeTokenSchema = z.string().min(1).max(512); -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 AuthPreflightBodySchema = z + .object({ email: EmailSchema }) + .strict(); -export const OAuthStateEnvelopeSchema = z.object({ - state: z.string().min(1), - provider: z.string().min(1).optional(), -}); +export const SignInBodySchema = z + .object({ + email: EmailSchema, + password: z.string().min(1).max(1024).optional(), + callbackUrl: z.string().min(1).max(2048).optional(), + client_nonce: ClientNonceSchema.optional(), + // TODO(auth-session): remove these ignored body fields after Electron 0.26.x + // compatibility is dropped; captcha credentials belong in request headers. + verifyToken: z.string().max(4096).optional(), + challenge: z.string().max(4096).optional(), + }) + .strict(); + +export const MagicLinkBodySchema = z + .object({ + email: EmailSchema, + token: ChallengeTokenSchema, + client_nonce: ClientNonceSchema.optional(), + }) + .strict(); + +export const OpenAppSignInBodySchema = z + .object({ code: ChallengeTokenSchema }) + .strict(); + +export const AuthSessionExchangeBodySchema = z + .object({ + code: ChallengeTokenSchema, + installationId: z.string().uuid(), + platform: z.enum(['ios', 'android', 'electron']), + deviceName: z.string().trim().min(1).max(200).optional(), + }) + .strict(); + +export const AuthSessionRefreshBodySchema = z + .object({ + refreshToken: z.string().min(1).max(512), + }) + .strict(); export function getSessionOptionsFromRequest(req: Request) { const sessionId = SessionIdSchema.safeParse( diff --git a/packages/backend/server/src/core/auth/job.ts b/packages/backend/server/src/core/auth/job.ts index 91948d7969..19f69289c4 100644 --- a/packages/backend/server/src/core/auth/job.ts +++ b/packages/backend/server/src/core/auth/job.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { JobQueue, OnJob } from '../../base'; import { BackendRuntimeProvider } from '../backend-runtime'; +import { AuthSessionService } from './auth-session'; declare global { interface Jobs { @@ -14,6 +15,7 @@ declare global { export class AuthCronJob { constructor( private readonly rt: BackendRuntimeProvider, + private readonly authSessions: AuthSessionService, private readonly queue: JobQueue ) {} @@ -35,5 +37,6 @@ export class AuthCronJob { const count = await this.rt.cleanupExpiredUserSessions(1000); if (count < 1000) break; } + await this.authSessions.cleanup(); } } diff --git a/packages/backend/server/src/core/auth/jwt-session.ts b/packages/backend/server/src/core/auth/jwt-session.ts deleted file mode 100644 index 9659e29b55..0000000000 --- a/packages/backend/server/src/core/auth/jwt-session.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import jwt, { type JwtPayload } from 'jsonwebtoken'; - -import { AuthenticationRequired, Config, 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'; - -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 readonly config: Config - ) {} - - 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 ttl = this.config.auth.session.ttl; - const expiresAt = new Date(Date.now() + ttl * 1000); - const token = jwt.sign( - { sid: sessionId, typ: JWT_SESSION_TYPE }, - this.currentKey, - { - algorithm: 'HS256', - audience: JWT_SESSION_AUDIENCE, - expiresIn: ttl, - issuer: JWT_SESSION_ISSUER, - subject: userId, - } - ); - - return { token, expiresAt }; - } - - async verify(token: string): Promise { - 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; -} diff --git a/packages/backend/server/src/core/auth/native-exchange.ts b/packages/backend/server/src/core/auth/native-exchange.ts deleted file mode 100644 index c7d196d029..0000000000 --- a/packages/backend/server/src/core/auth/native-exchange.ts +++ /dev/null @@ -1,59 +0,0 @@ -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( - '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( - '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); - } -} diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index eb75404839..c5bfe840f7 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -79,7 +79,7 @@ export class AuthResolver { @ResolveField(() => ClientTokenType, { name: 'token', - deprecationReason: 'use native session exchange instead', + deprecationReason: 'use auth session exchange instead', }) async clientToken( @CurrentUser() currentUser: CurrentUser, @@ -122,8 +122,7 @@ export class AuthResolver { throw new InvalidEmailToken(); } - await this.auth.changePassword(userId, newPassword); - await this.auth.revokeUserSessions(userId); + await this.auth.changePasswordAndRevokeSessions(userId, newPassword); return true; } @@ -149,8 +148,7 @@ export class AuthResolver { email = decodeURIComponent(email); - await this.auth.changeEmail(user.id, email); - await this.auth.revokeUserSessions(user.id); + await this.auth.changeEmailAndRevokeSessions(user.id, email); await this.auth.sendNotificationChangeEmail(email); return user; diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index d21b78c98f..cdff69f8ca 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -1,13 +1,15 @@ import { randomUUID } from 'node:crypto'; import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; import type { CookieOptions, Request, Response } from 'express'; import { assign, pick } from 'lodash-es'; -import { Config, SignUpForbidden } from '../../base'; +import { Config, OnEvent, SignUpForbidden } from '../../base'; import { Models, type User, type UserSession } from '../../models'; import { Mailer } from '../mail/mailer'; import type { MailDeliveryMetadata } from '../mail/types'; +import { AuthSessionService } from './auth-session'; import { createDevUsers } from './dev'; import type { VerifiedIdentity } from './identity'; import { @@ -42,7 +44,8 @@ export class AuthService implements OnApplicationBootstrap { constructor( private readonly config: Config, private readonly models: Models, - private readonly mailer: Mailer + private readonly mailer: Mailer, + private readonly authSessions: AuthSessionService ) { this.cookieOptions = { sameSite: 'lax', @@ -206,8 +209,22 @@ export class AuthService implements OnApplicationBootstrap { return true; } - async revokeUserSessions(userId: string) { - return await this.models.session.deleteUserSessions(userId); + @Transactional() + async revokeUserSessions(userId: string, reason = 'security_action') { + const authSessions = await this.authSessions.revokeUserSessions( + userId, + reason + ); + const cookieSessions = await this.models.session.deleteUserSessions(userId); + return cookieSessions + authSessions; + } + + @OnEvent('auth.sessions.revoke_requested') + async onRevokeRequested({ + userId, + reason, + }: Events['auth.sessions.revoke_requested']) { + await this.revokeUserSessions(userId, reason); } async refreshCookies(res: Response, sessionId?: string) { @@ -297,6 +314,13 @@ export class AuthService implements OnApplicationBootstrap { return this.models.user.update(id, { password: newPassword }); } + @Transactional() + async changePasswordAndRevokeSessions(id: string, newPassword: string) { + const user = await this.changePassword(id, newPassword); + await this.revokeUserSessions(id); + return user; + } + async changeEmail( id: string, newEmail: string @@ -307,6 +331,13 @@ export class AuthService implements OnApplicationBootstrap { }); } + @Transactional() + async changeEmailAndRevokeSessions(id: string, newEmail: string) { + const user = await this.changeEmail(id, newEmail); + await this.revokeUserSessions(id); + return user; + } + async setEmailVerified(id: string) { return await this.models.user.update(id, { emailVerifiedAt: new Date(), diff --git a/packages/backend/server/src/core/auth/session-exchange.ts b/packages/backend/server/src/core/auth/session-exchange.ts new file mode 100644 index 0000000000..f6b949014b --- /dev/null +++ b/packages/backend/server/src/core/auth/session-exchange.ts @@ -0,0 +1,159 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import type { Request } from 'express'; + +import { + ActionForbidden, + Cache, + InvalidAuthState, + TooManyRequest, + UserFriendlyError, +} from '../../base'; +import { Models } from '../../models'; +import { AccessTokenService } from './access-token'; +import { AuthSessionErrorCode, AuthSessionService } from './auth-session'; +import { AuthChallengeStore } from './challenge-store'; +import { isNativeClientRequest } from './input'; +import { AuthService } from './service'; + +interface SessionExchangePayload { + userId: string; + clientVersion?: string; +} + +export interface AuthSessionMetadata { + installationId: string; + platform: 'ios' | 'android' | 'electron'; + deviceName?: string; + appVersion?: string; +} + +export class AuthSessionHttpError extends UserFriendlyError { + readonly authCode: string; + + constructor(code: string, status = HttpStatus.UNAUTHORIZED) { + super( + status === HttpStatus.SERVICE_UNAVAILABLE + ? 'network_error' + : 'authentication_required', + code.toLowerCase() as + | 'access_token_expired' + | 'access_token_invalid' + | 'auth_session_expired' + | 'auth_session_revoked' + | 'refresh_token_invalid' + | 'refresh_token_reused' + | 'auth_session_temporarily_unavailable' + ); + this.authCode = code; + this.status = status; + } + + override toJSON() { + return { + ...super.toJSON(), + code: this.authCode, + }; + } +} + +@Injectable() +export class SessionExchangeService { + constructor( + private readonly auth: AuthService, + private readonly challenges: AuthChallengeStore, + private readonly cache: Cache, + private readonly models: Models, + private readonly accessTokens: AccessTokenService, + private readonly authSessions: AuthSessionService + ) {} + + async createCode(req: Request, userId: string, clientVersion?: string) { + if (!isNativeClientRequest(req)) return; + return this.challenges.create( + 'auth_session_exchange', + { userId, clientVersion }, + 60 * 1000 + ); + } + + @Transactional() + async exchange(req: Request, code: string, metadata: AuthSessionMetadata) { + if (!isNativeClientRequest(req)) throw new ActionForbidden(); + const payload = await this.challenges.consume( + 'auth_session_exchange', + code + ); + if (!payload?.userId) throw new InvalidAuthState(); + const user = await this.models.user.lockForAuthIssuance(payload.userId); + if (!user || user.disabled) throw new InvalidAuthState(); + const userSession = await this.auth.createUserSession( + payload.userId, + undefined, + undefined, + payload.clientVersion + ); + + const issued = await this.authSessions.create({ + userSessionId: userSession.id, + ...metadata, + }); + return this.tokenPair( + payload.userId, + issued.session.id, + issued.refreshToken, + issued.refreshExpiresAt, + issued.session.absoluteExpiresAt + ); + } + + async refresh(req: Request, refreshToken: string, appVersion?: string) { + if (!isNativeClientRequest(req)) throw new ActionForbidden(); + const selector = refreshToken.split('.')[1]; + if (selector) { + const rateKey = `auth:session-refresh-rate:${selector}`; + const attempts = await this.cache.increaseWithTtl(rateKey, 60_000); + if (attempts > 30) throw new TooManyRequest(); + } + const refreshed = await this.authSessions.refresh(refreshToken, appVersion); + if (refreshed.status !== 'rotated') { + const status = + refreshed.code === AuthSessionErrorCode.temporarilyUnavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : HttpStatus.UNAUTHORIZED; + throw new AuthSessionHttpError(refreshed.code, status); + } + const session = await this.authSessions.get(refreshed.authSessionId); + if (!session) { + throw new AuthSessionHttpError(AuthSessionErrorCode.revoked); + } + return this.tokenPair( + session.userSession.userId, + refreshed.authSessionId, + refreshed.refreshToken, + refreshed.refreshExpiresAt, + session.absoluteExpiresAt + ); + } + + private async tokenPair( + userId: string, + authSessionId: string, + refreshToken: string, + refreshTokenExpiresAt: Date, + absoluteExpiresAt: Date + ) { + const access = await this.accessTokens.sign(userId, authSessionId); + return { + tokenType: 'Bearer', + accessToken: access.token, + expiresIn: this.authSessions.accessTokenTtl, + refreshToken, + refreshExpiresAt: refreshTokenExpiresAt, + session: { + id: authSessionId, + absoluteExpiresAt, + }, + }; + } +} diff --git a/packages/backend/server/src/core/auth/session-issuer.ts b/packages/backend/server/src/core/auth/session-issuer.ts index 7efce627d9..a881cb5c3f 100644 --- a/packages/backend/server/src/core/auth/session-issuer.ts +++ b/packages/backend/server/src/core/auth/session-issuer.ts @@ -6,12 +6,12 @@ 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'; +import { SessionExchangeService } from './session-exchange'; export type IssuedSession = { userId: string; - sessionId: string; + sessionId?: string; exchangeCode?: string; }; @@ -28,12 +28,24 @@ export class SessionIssuer { identity: VerifiedIdentity ): Promise { const nativeClient = isNativeClientRequest(req); + const signInClientVersion = + identity.clientVersion ?? getClientVersionFromRequest(req); + if (nativeClient) { + this.auth.clearCookies(res); + return { + userId: identity.userId, + exchangeCode: await this.sessionExchange.createCode( + req, + identity.userId, + signInClientVersion + ), + }; + } + 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, @@ -41,33 +53,22 @@ export class SessionIssuer { 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.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, - }); + 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 - ); + this.auth.setUserCookie(res, identity.userId); return { userId: identity.userId, sessionId: userSession.sessionId, - exchangeCode, }; } } diff --git a/packages/backend/server/src/core/auth/session.ts b/packages/backend/server/src/core/auth/session.ts index 1eea7907a0..89c89bc80a 100644 --- a/packages/backend/server/src/core/auth/session.ts +++ b/packages/backend/server/src/core/auth/session.ts @@ -66,6 +66,11 @@ export type Session = UserSession & { user: CurrentUser; }; +export type AuthSessionPrincipal = Session & { + authSessionId: string; + authenticatedAt: Date; +}; + export type TokenSession = AccessToken & { user: CurrentUser; }; diff --git a/packages/backend/server/src/core/auth/signing-key-resolver.ts b/packages/backend/server/src/core/auth/signing-key-resolver.ts new file mode 100644 index 0000000000..0efaac3a8c --- /dev/null +++ b/packages/backend/server/src/core/auth/signing-key-resolver.ts @@ -0,0 +1,63 @@ +import { + Args, + Field, + Mutation, + ObjectType, + Query, + Resolver, +} from '@nestjs/graphql'; + +import { Admin } from '../common'; +import { CurrentUser, type CurrentUser as CurrentUserType } from './session'; +import { AuthSigningKeyRing } from './signing-key'; + +@ObjectType() +class AuthSigningKeyType { + @Field() + id!: string; + + @Field() + status!: string; + + @Field() + source!: string; + + @Field(() => Date, { nullable: true }) + createdAt?: Date; + + @Field(() => Date, { nullable: true }) + retiredAt?: Date; + + @Field(() => Date, { nullable: true }) + verifyUntil?: Date; + + @Field() + canDelete!: boolean; +} + +@Admin() +@Resolver(() => AuthSigningKeyType) +export class AuthSigningKeyResolver { + constructor(private readonly signingKeys: AuthSigningKeyRing) {} + + @Query(() => [AuthSigningKeyType]) + async authSigningKeys() { + return await this.signingKeys.metadata(); + } + + @Mutation(() => [AuthSigningKeyType]) + async rotateAuthSigningKey( + @CurrentUser() me: CurrentUserType, + @Args('expectedActiveKeyId') expectedActiveKeyId: string + ) { + return await this.signingKeys.rotate(me.id, expectedActiveKeyId); + } + + @Mutation(() => [AuthSigningKeyType]) + async deleteAuthSigningKey( + @CurrentUser() me: CurrentUserType, + @Args('id') id: string + ) { + return await this.signingKeys.delete(me.id, id); + } +} diff --git a/packages/backend/server/src/core/auth/signing-key.ts b/packages/backend/server/src/core/auth/signing-key.ts new file mode 100644 index 0000000000..e7425ef8df --- /dev/null +++ b/packages/backend/server/src/core/auth/signing-key.ts @@ -0,0 +1,287 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { z } from 'zod'; + +import { + ActionForbidden, + Config, + CryptoHelper, + EventBus, + InvalidAppConfigInput, + OnEvent, +} from '../../base'; +import { Models } from '../../models'; + +const SIGNING_KEY_STORE_ID = 'auth.session.signingKeys'; +const CLOCK_SKEW_SECONDS = 30; +const signingKeySchema = z + .object({ + id: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/), + secret: z.string().min(1), + status: z.enum(['active', 'retiring']), + createdAt: z.string().datetime().optional(), + source: z.enum(['auto', 'admin']), + retiredAt: z.string().datetime().optional(), + verifyUntil: z.string().datetime().optional(), + }) + .strict(); +const signingKeysSchema = z.array(signingKeySchema); + +type PersistedAuthSigningKey = z.infer; + +export interface AuthSigningKey { + id: string; + secret: Buffer; + status: 'active' | 'retiring'; + createdAt?: Date; + source: 'auto' | 'admin'; + retiredAt?: Date; + verifyUntil?: Date; +} + +export interface AuthSigningKeyMetadata { + id: string; + status: 'active' | 'retiring'; + createdAt?: Date; + source: 'auto' | 'admin'; + retiredAt?: Date; + verifyUntil?: Date; + canDelete: boolean; +} + +declare global { + interface Events { + 'auth.signing_keys.changed': Record; + 'auth.signing_key.rotated': { + actorId: string; + previousKeyId: string; + activeKeyId: string; + }; + 'auth.signing_key.deleted': { + actorId: string; + keyId: string; + }; + } +} + +@Injectable() +export class AuthSigningKeyRing { + private readonly logger = new Logger(AuthSigningKeyRing.name); + private snapshot: AuthSigningKey[] | undefined; + + constructor( + private readonly config: Config, + private readonly crypto: CryptoHelper, + private readonly models: Models, + private readonly event: EventBus + ) {} + + @OnEvent('config.init', { prepend: true }) + async onConfigInit() { + const stored = await this.models.appConfig.createIfAbsent( + SIGNING_KEY_STORE_ID, + [this.generate('auto')] + ); + this.applyPersisted(stored.value); + this.logger.log('Initialized auth signing key ring from the database.'); + } + + @OnEvent('auth.signing_keys.changed') + async onSigningKeysChanged() { + await this.reconcile(); + } + + async active() { + await this.reconcile(); + const active = this.keys().find(key => key.status === 'active'); + if (!active) { + throw new Error('Auth session requires exactly one active signing key.'); + } + return active; + } + + async verify(id: string, now = new Date()) { + await this.reconcile(); + return this.keys().find( + key => + key.id === id && + (key.status === 'active' || + (!!key.verifyUntil && key.verifyUntil >= now)) + ); + } + + async metadata(): Promise { + await this.reconcile(); + return this.snapshotMetadata(); + } + + private snapshotMetadata(): AuthSigningKeyMetadata[] { + const now = new Date(); + return this.keys().map(({ secret: _, ...key }) => ({ + ...key, + canDelete: + key.status === 'retiring' && !!key.verifyUntil && key.verifyUntil < now, + })); + } + + async rotate(actorId: string, expectedActiveKeyId: string) { + const replacement = this.generate('admin'); + const now = new Date(); + const verifyUntil = new Date( + now.getTime() + + (this.config.auth.token.accessTokenTtl + CLOCK_SKEW_SECONDS) * 1000 + ); + const updated = await this.models.appConfig.mutate( + SIGNING_KEY_STORE_ID, + actorId, + value => { + const current = this.parse(value); + const active = current.find(key => key.status === 'active'); + if (!active) { + throw new Error( + 'Auth session requires exactly one active signing key.' + ); + } + if (active.id !== expectedActiveKeyId) { + throw new InvalidAppConfigInput({ + message: 'The active signing key changed. Reload and try again.', + }); + } + return [ + ...current.map(key => + key.status === 'active' + ? { + ...key, + status: 'retiring' as const, + retiredAt: now.toISOString(), + verifyUntil: verifyUntil.toISOString(), + } + : key + ), + replacement, + ]; + } + ); + this.applyPersisted(updated.value); + this.event.emit('auth.signing_key.rotated', { + actorId, + previousKeyId: expectedActiveKeyId, + activeKeyId: replacement.id, + }); + this.event.broadcast('auth.signing_keys.changed', {}); + return this.snapshotMetadata(); + } + + async delete(actorId: string, keyId: string) { + const now = new Date(); + const updated = await this.models.appConfig.mutate( + SIGNING_KEY_STORE_ID, + actorId, + value => { + const current = this.parse(value); + const key = current.find(key => key.id === keyId); + if (!key) { + throw new InvalidAppConfigInput({ + message: 'Signing key does not exist.', + }); + } + if ( + key.status !== 'retiring' || + !key.verifyUntil || + new Date(key.verifyUntil) >= now + ) { + throw new ActionForbidden(); + } + return current.filter(key => key.id !== keyId); + } + ); + this.applyPersisted(updated.value); + this.event.emit('auth.signing_key.deleted', { actorId, keyId }); + this.event.broadcast('auth.signing_keys.changed', {}); + return this.snapshotMetadata(); + } + + private applyPersisted(value: unknown) { + const persisted = this.parse(value); + this.replaceSnapshot(persisted); + } + + private replaceSnapshot(keys: unknown) { + const persisted = this.parse(keys); + this.snapshot = persisted.map(key => { + const secret = Buffer.from(key.secret, 'base64url'); + return { + id: key.id, + secret, + status: key.status, + createdAt: key.createdAt ? new Date(key.createdAt) : undefined, + source: key.source, + retiredAt: key.retiredAt ? new Date(key.retiredAt) : undefined, + verifyUntil: key.verifyUntil ? new Date(key.verifyUntil) : undefined, + }; + }); + } + + private parse(value: unknown) { + const keys = signingKeysSchema.parse(value); + const ids = new Set(keys.map(key => key.id)); + if (ids.size !== keys.length) { + throw new Error('Auth session signing key ids must be unique.'); + } + if (keys.filter(key => key.status === 'active').length !== 1) { + throw new Error('Auth session requires exactly one active signing key.'); + } + for (const key of keys) { + const secret = Buffer.from(key.secret, 'base64url'); + if (secret.length < 32 || secret.toString('base64url') !== key.secret) { + throw new Error( + `Auth session signing key ${key.id} must be canonical base64url containing at least 32 bytes.` + ); + } + if (key.status === 'retiring' && (!key.retiredAt || !key.verifyUntil)) { + throw new Error( + `Retiring auth session signing key ${key.id} requires retiredAt and verifyUntil.` + ); + } + if ( + key.retiredAt && + key.verifyUntil && + new Date(key.verifyUntil).getTime() - + new Date(key.retiredAt).getTime() < + (this.config.auth.token.accessTokenTtl + CLOCK_SKEW_SECONDS) * 1000 + ) { + throw new Error( + `Retiring auth session signing key ${key.id} must remain verifiable for the access token lifetime.` + ); + } + } + return keys; + } + + private async reconcile() { + const stored = await this.models.appConfig.get(SIGNING_KEY_STORE_ID); + if (!stored) { + throw new Error('Persisted auth signing key ring is missing.'); + } + this.applyPersisted(stored.value); + } + + private generate( + source: PersistedAuthSigningKey['source'], + id = `auth-${Date.now().toString(36)}-${this.crypto.randomBytes(6).toString('base64url')}` + ): PersistedAuthSigningKey { + return { + id, + secret: this.crypto.randomBytes(32).toString('base64url'), + status: 'active', + createdAt: new Date().toISOString(), + source, + }; + } + + private keys() { + if (!this.snapshot) { + throw new Error('Auth signing key ring is not initialized.'); + } + return this.snapshot; + } +} diff --git a/packages/backend/server/src/core/config/__tests__/service.spec.ts b/packages/backend/server/src/core/config/__tests__/service.spec.ts index 098b69d90c..52fcc2d81e 100644 --- a/packages/backend/server/src/core/config/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/config/__tests__/service.spec.ts @@ -72,6 +72,17 @@ test('should validate config before update', async t => { service.getConfig().auth['unknown-key'], undefined ); + + await t.throwsAsync( + service.updateConfig(user.id, [ + { + module: 'auth', + key: 'token.signingKeys', + value: [{ secret: 'must-not-enter-app-config' }], + }, + ]), + { instanceOf: InvalidAppConfigInput } + ); }); test('should emit config.init event', async t => { diff --git a/packages/backend/server/src/core/config/service.ts b/packages/backend/server/src/core/config/service.ts index 853a02f579..92ec7a9ad1 100644 --- a/packages/backend/server/src/core/config/service.ts +++ b/packages/backend/server/src/core/config/service.ts @@ -73,7 +73,7 @@ export class ServerService implements OnApplicationBootstrap { user: string, updates: Array<{ module: string; key: string; value: any }> ): Promise> { - const errors = this.configFactory.validate(updates); + const errors = this.validateConfig(updates); if (errors?.length) { throw new InvalidAppConfigInput({ @@ -127,13 +127,15 @@ export class ServerService implements OnApplicationBootstrap { const overrides = await this.loadDbOverrides(); this.configFactory.override(overrides); await this.event.emitAsync('config.init', { - config: this.configFactory.config, + config: this.getConfig(), }); this.onFlagsChanged(); } private async loadDbOverrides() { - const configs = await this.models.appConfig.load(); + const configs = await this.models.appConfig.load([ + 'auth.session.signingKeys', + ]); const overrides: DeepPartial = {}; configs.forEach(config => { diff --git a/packages/backend/server/src/core/workspaces/abuse.ts b/packages/backend/server/src/core/workspaces/abuse.ts index 1b99d40a2c..bcdde29b03 100644 --- a/packages/backend/server/src/core/workspaces/abuse.ts +++ b/packages/backend/server/src/core/workspaces/abuse.ts @@ -7,6 +7,7 @@ import type { Request } from 'express'; import { ActionForbidden, Config, + EventBus, getRequestClientIp, JobQueue, metrics, @@ -89,7 +90,8 @@ export class InviteAbuseDispositionService { constructor( private readonly models: Models, private readonly runtime: BackendRuntimeProvider, - private readonly queue: JobQueue + private readonly queue: JobQueue, + private readonly event: EventBus ) {} async execute(input: { @@ -119,12 +121,14 @@ export class InviteAbuseDispositionService { switch (actionRequired.action) { case 'ban_actor': await this.cancelPendingActorArtifacts(input, actionRequired); - await this.models.session.deleteUserSessions(input.actorUserId); await this.models.user.ban(input.actorUserId); break; case 'quarantine_actor': await this.cancelPendingActorArtifacts(input, actionRequired); - await this.models.session.deleteUserSessions(input.actorUserId); + await this.event.emitAsync('auth.sessions.revoke_requested', { + userId: input.actorUserId, + reason: 'abuse_quarantine', + }); break; case 'quarantine_workspace': await this.cancelPendingWorkspaceArtifacts(input.workspaceId); diff --git a/packages/backend/server/src/models/auth-session.ts b/packages/backend/server/src/models/auth-session.ts new file mode 100644 index 0000000000..79b5c8b498 --- /dev/null +++ b/packages/backend/server/src/models/auth-session.ts @@ -0,0 +1,328 @@ +import { timingSafeEqual } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; + +import { BaseModel } from './base'; + +export interface CreateAuthSessionInput { + userSessionId: string; + installationId: string; + platform: string; + deviceName?: string; + appVersion?: string; + idleExpiresAt: Date; + absoluteExpiresAt: Date; + refreshToken: { + id: string; + secretHash: string; + expiresAt: Date; + }; +} + +export interface RotateAuthRefreshTokenInput { + id: string; + secretHash: string; + now: Date; + idleExpiresAt: Date; + graceMs: number; + appVersion?: string; + next: { + id: string; + secretHash: string; + expiresAt: Date; + }; +} + +export type RotateAuthRefreshTokenResult = + | { + status: 'rotated' | 'grace'; + authSessionId: string; + userSessionId: string; + platform: string; + } + | { status: 'reused'; authSessionId: string; platform: string } + | { status: 'invalid' } + | { status: 'expired' } + | { status: 'revoked' } + | { status: 'temporarily_unavailable' }; + +@Injectable() +export class AuthSessionModel extends BaseModel { + async hasUserInstallation(userSessionId: string, installationId: string) { + const userSession = await this.db.userSession.findUnique({ + where: { id: userSessionId }, + select: { userId: true }, + }); + if (!userSession) return false; + await this.db + .$queryRaw`SELECT id FROM users WHERE id = ${userSession.userId} FOR UPDATE`; + return ( + (await this.db.authSession.count({ + where: { + installationId, + userSession: { userId: userSession.userId }, + }, + })) > 0 + ); + } + + @Transactional() + async create(input: CreateAuthSessionInput) { + const session = await this.db.authSession.create({ + data: { + userSessionId: input.userSessionId, + installationId: input.installationId, + platform: input.platform, + deviceName: input.deviceName, + appVersion: input.appVersion, + idleExpiresAt: input.idleExpiresAt, + absoluteExpiresAt: input.absoluteExpiresAt, + refreshTokens: { + create: { + id: input.refreshToken.id, + generation: 0, + secretHash: input.refreshToken.secretHash, + expiresAt: input.refreshToken.expiresAt, + }, + }, + }, + }); + await this.db.userSession.update({ + where: { id: input.userSessionId }, + data: { + expiresAt: input.idleExpiresAt, + refreshClientVersion: input.appVersion, + }, + }); + return session; + } + + async get(id: string) { + return await this.db.authSession.findUnique({ + where: { id }, + include: { userSession: true }, + }); + } + + async list(userId: string) { + const now = new Date(); + return await this.db.authSession.findMany({ + where: { + userSession: { + userId, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + revokedAt: null, + idleExpiresAt: { gt: now }, + absoluteExpiresAt: { gt: now }, + }, + orderBy: { lastSeenAt: 'desc' }, + }); + } + + async revokeUserSessions(userId: string, reason: string) { + return await this.db.authSession.updateMany({ + where: { + userSession: { userId }, + revokedAt: null, + }, + data: { + revokedAt: new Date(), + revokeReason: reason, + }, + }); + } + + async revoke(id: string, reason: string, userId?: string) { + const now = new Date(); + const where = { + id, + revokedAt: null, + ...(userId ? { userSession: { userId } } : {}), + }; + const { count } = await this.db.authSession.updateMany({ + where, + data: { revokedAt: now, revokeReason: reason }, + }); + if (count) { + await this.db.authRefreshToken.updateMany({ + where: { authSessionId: id, revokedAt: null }, + data: { revokedAt: now }, + }); + } + return count > 0; + } + + async findByRefreshToken(id: string, secretHash: string) { + const token = await this.db.authRefreshToken.findUnique({ + where: { id }, + select: { secretHash: true, authSessionId: true }, + }); + return token && this.secretHashesEqual(token.secretHash, secretHash) + ? token.authSessionId + : undefined; + } + + @Transactional() + async rotate( + input: RotateAuthRefreshTokenInput + ): Promise { + await this.db.$queryRaw` + SELECT id + FROM auth_refresh_tokens + WHERE id = ${input.id} + FOR UPDATE + `; + + let token = await this.db.authRefreshToken.findUnique({ + where: { id: input.id }, + include: { + authSession: { + include: { userSession: { include: { user: true } } }, + }, + }, + }); + if (!token || !this.secretHashesEqual(token.secretHash, input.secretHash)) { + return { status: 'invalid' }; + } + + const session = token.authSession; + if (token.revokedAt || session.revokedAt) return { status: 'revoked' }; + if (session.userSession.user.disabled) { + await this.revoke(session.id, 'user_disabled'); + return { status: 'revoked' }; + } + if ( + token.expiresAt <= input.now || + session.idleExpiresAt <= input.now || + session.absoluteExpiresAt <= input.now || + (session.userSession.expiresAt && + session.userSession.expiresAt <= input.now) + ) { + return { status: 'expired' }; + } + + if (token.usedAt) { + const replacementId = token.replacedById; + if ( + token.graceUsedAt || + !replacementId || + input.now.getTime() - token.usedAt.getTime() > input.graceMs + ) { + await this.revoke(session.id, 'refresh_token_reused'); + return { + status: 'reused', + authSessionId: session.id, + platform: session.platform, + }; + } + + const claimed = await this.db.authRefreshToken.updateMany({ + where: { + id: token.id, + graceUsedAt: null, + replacedById: input.next.id, + replacedBy: { secretHash: input.next.secretHash }, + }, + data: { graceUsedAt: input.now }, + }); + if (!claimed.count) { + return { status: 'temporarily_unavailable' }; + } + return { + status: 'grace', + authSessionId: session.id, + userSessionId: session.userSessionId, + platform: session.platform, + }; + } + + await this.db.authRefreshToken.create({ + data: { + id: input.next.id, + authSessionId: session.id, + generation: token.generation + 1, + secretHash: input.next.secretHash, + expiresAt: input.next.expiresAt, + }, + }); + await this.db.authRefreshToken.update({ + where: { id: token.id }, + data: { usedAt: input.now, replacedById: input.next.id }, + }); + await this.db.authSession.update({ + where: { id: session.id }, + data: { + lastSeenAt: input.now, + idleExpiresAt: + input.idleExpiresAt < session.absoluteExpiresAt + ? input.idleExpiresAt + : session.absoluteExpiresAt, + appVersion: input.appVersion, + }, + }); + await this.db.userSession.update({ + where: { id: session.userSessionId }, + data: { + expiresAt: + input.idleExpiresAt < session.absoluteExpiresAt + ? input.idleExpiresAt + : session.absoluteExpiresAt, + refreshClientVersion: input.appVersion, + }, + }); + + return { + status: 'rotated', + authSessionId: session.id, + userSessionId: session.userSessionId, + platform: session.platform, + }; + } + + async deleteExpiredRefreshTokens(before: Date, limit: number) { + const rows = await this.db.authRefreshToken.findMany({ + where: { + OR: [ + { expiresAt: { lt: before } }, + { authSession: { revokedAt: { lt: before } } }, + ], + }, + select: { id: true }, + take: limit, + }); + if (!rows.length) return 0; + const { count } = await this.db.authRefreshToken.deleteMany({ + where: { id: { in: rows.map(row => row.id) } }, + }); + return count; + } + + async deleteExpiredSessions(before: Date, limit: number) { + const rows = await this.db.authSession.findMany({ + where: { + OR: [ + { absoluteExpiresAt: { lt: before } }, + { revokedAt: { lt: before } }, + ], + }, + select: { id: true }, + take: limit, + }); + if (!rows.length) return 0; + const { count } = await this.db.authSession.deleteMany({ + where: { id: { in: rows.map(row => row.id) } }, + }); + return count; + } + + private secretHashesEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left, 'hex'); + const rightBuffer = Buffer.from(right, 'hex'); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); + } +} diff --git a/packages/backend/server/src/models/config.ts b/packages/backend/server/src/models/config.ts index 2847a2945c..2aa2de0f91 100644 --- a/packages/backend/server/src/models/config.ts +++ b/packages/backend/server/src/models/config.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; +import { Prisma } from '@prisma/client'; import { BaseModel } from './base'; @Injectable() export class AppConfigModel extends BaseModel { - async load() { - return this.db.appConfig.findMany(); + async load(excludedKeys: string[] = []) { + return this.db.appConfig.findMany({ + where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined, + }); } @Transactional() @@ -21,4 +24,44 @@ export class AppConfigModel extends BaseModel { }) ); } + + async get(key: string) { + return await this.db.appConfig.findUnique({ where: { id: key } }); + } + + async createIfAbsent(key: string, value: Prisma.InputJsonValue) { + try { + return await this.db.appConfig.create({ + data: { id: key, value, lastUpdatedBy: null }, + }); + } catch (error) { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== 'P2002' + ) { + throw error; + } + return await this.db.appConfig.findUniqueOrThrow({ where: { id: key } }); + } + } + + @Transactional() + async mutate( + key: string, + actorId: string, + mutator: (value: Prisma.JsonValue) => Prisma.InputJsonValue + ) { + await this.db + .$queryRaw`SELECT id FROM app_configs WHERE id = ${key} FOR UPDATE`; + const current = await this.db.appConfig.findUniqueOrThrow({ + where: { id: key }, + }); + return await this.db.appConfig.update({ + where: { id: key }, + data: { + value: mutator(current.value), + lastUpdatedBy: actorId, + }, + }); + } } diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 005f8d2ce6..8d1f447938 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; import { AccessTokenModel } from './access-token'; +import { AuthSessionModel } from './auth-session'; import { BlobModel } from './blob'; import { CalendarAccountModel } from './calendar-account'; import { CalendarEventModel } from './calendar-event'; @@ -59,6 +60,7 @@ const MODELS = { verificationToken: VerificationTokenModel, magicLinkOtp: MagicLinkOtpModel, mailDelivery: MailDeliveryModel, + authSession: AuthSessionModel, feature: FeatureModel, workspace: WorkspaceModel, userFeature: UserFeatureModel, @@ -147,6 +149,7 @@ const ModelsSymbolProvider: ExistingProvider = { }) export class ModelsModule {} +export * from './auth-session'; export * from './blob'; export * from './calendar-account'; export * from './calendar-event'; diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index 7e7b8fe0cd..c0ba3610a9 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -35,6 +35,7 @@ type UpdateConnectedAccountInput = Omit< declare global { interface Events { + 'user.preDelete': { id: string }; 'user.created': User; 'user.updated': User; 'user.deleted': User & { @@ -60,6 +61,16 @@ export type { ConnectedAccount, User }; @Injectable() export class UserModel extends BaseModel { + async lockForAuthIssuance(id: string) { + const users = await this.db.$queryRaw>>` + SELECT id, disabled + FROM users + WHERE id = ${id} + FOR UPDATE + `; + return users[0]; + } + constructor( private readonly crypto: CryptoHelper, private readonly event: EventBus @@ -284,6 +295,8 @@ export class UserModel extends BaseModel { } } + await this.event.emitAsync('user.preDelete', { id }); + await this.db.workspaceInvitation.deleteMany({ where: { inviteeUserId: id }, }); diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index a33e2a22b1..ae6040c760 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -167,6 +167,16 @@ import type { } from './plugins/copilot/runtime/contracts/tool-contract'; export const mergeUpdatesInApplyWay = serverNativeModule.mergeUpdatesInApplyWay; +export const authSessionAccessTokenKeyId = + serverNativeModule.authSessionAccessTokenKeyId; +export const createAuthSessionRefreshToken = + serverNativeModule.createAuthSessionRefreshToken; +export const parseAuthSessionRefreshToken = + serverNativeModule.parseAuthSessionRefreshToken; +export const signAuthSessionAccessToken = + serverNativeModule.signAuthSessionAccessToken; +export const verifyAuthSessionAccessToken = + serverNativeModule.verifyAuthSessionAccessToken; export async function validateDocUpdate( update: Buffer, diff --git a/packages/backend/server/src/plugins/captcha/config.ts b/packages/backend/server/src/plugins/captcha/config.ts index 9955fbdcf7..976fbb1ae1 100644 --- a/packages/backend/server/src/plugins/captcha/config.ts +++ b/packages/backend/server/src/plugins/captcha/config.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { defineModuleConfig } from '../../base'; import { CaptchaConfig } from './types'; @@ -26,10 +28,26 @@ defineModuleConfig('captcha', { default: { turnstile: { secret: '', + siteKey: '', + action: 'auth-sign-in', }, challenge: { bits: 20, }, }, + shape: z + .object({ + turnstile: z + .object({ + secret: z.string().max(4096), + siteKey: z.string().max(256), + action: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), + }) + .strict(), + challenge: z + .object({ bits: z.number().int().min(16).max(30) }) + .strict(), + }) + .strict(), }, }); diff --git a/packages/backend/server/src/plugins/captcha/controller.ts b/packages/backend/server/src/plugins/captcha/controller.ts index ce72e834e6..36569e414c 100644 --- a/packages/backend/server/src/plugins/captcha/controller.ts +++ b/packages/backend/server/src/plugins/captcha/controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Header, Req } from '@nestjs/common'; +import type { Request } from 'express'; import { Throttle } from '../../base'; import { Public } from '../../core/auth'; @@ -10,8 +11,11 @@ export class CaptchaController { constructor(private readonly captcha: CaptchaService) {} @Public() - @Get('/challenge') - async getChallenge() { - return this.captcha.getChallengeToken(); + @Get('/captcha') + @Header('Cache-Control', 'no-store') + async getChallenge(@Req() req: Request) { + return this.captcha.getClientConfig( + req.get('x-affine-client-kind') === 'native' + ); } } diff --git a/packages/backend/server/src/plugins/captcha/guard.ts b/packages/backend/server/src/plugins/captcha/guard.ts index ba2afe43bd..df7a90f598 100644 --- a/packages/backend/server/src/plugins/captcha/guard.ts +++ b/packages/backend/server/src/plugins/captcha/guard.ts @@ -33,14 +33,15 @@ export class CaptchaGuardProvider const { req } = getRequestResponseFromContext(context); - // require headers, old client send through query string - // x-captcha-token - // x-captcha-challenge - const token = req.headers['x-captcha-token'] ?? req.query['token']; - const challenge = - req.headers['x-captcha-challenge'] ?? req.query['challenge']; + const token = req.headers['x-captcha-token']; + const challenge = req.headers['x-captcha-challenge']; + const provider = req.headers['x-captcha-provider']; - const credential = this.captcha.assertValidCredential({ token, challenge }); + const credential = this.captcha.assertValidCredential({ + token, + challenge, + provider, + }); await this.captcha.verifyRequest(credential, req); return true; diff --git a/packages/backend/server/src/plugins/captcha/service.ts b/packages/backend/server/src/plugins/captcha/service.ts index c1d1c33fbe..849d6fc4f1 100644 --- a/packages/backend/server/src/plugins/captcha/service.ts +++ b/packages/backend/server/src/plugins/captcha/service.ts @@ -2,13 +2,14 @@ import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import type { Request } from 'express'; -import { nanoid } from 'nanoid'; import { z } from 'zod'; import { CaptchaVerificationFailed, Config, getRequestClientIp, + metrics, + NetworkError, OnEvent, } from '../../base'; import { ServerFeature, ServerService } from '../../core'; @@ -17,21 +18,31 @@ import { verifyChallengeResponse } from '../../native'; import { CaptchaConfig } from './types'; const validator = z - .object({ token: z.string(), challenge: z.string().optional() }) + .object({ + token: z.string().min(1).max(2048), + challenge: z.string().min(1).max(128).optional(), + provider: z.enum(['hashcash', 'turnstile']), + }) .strict(); type Credential = z.infer; +const turnstileResponse = z.object({ + success: z.boolean(), + hostname: z.string().optional(), + action: z.string().optional(), + 'error-codes': z.array(z.string()).optional(), +}); @Injectable() export class CaptchaService { private readonly logger = new Logger(CaptchaService.name); - private readonly captcha: CaptchaConfig; - constructor( private readonly config: Config, private readonly challenges: AuthChallengeStore, private readonly server: ServerService - ) { - this.captcha = config.captcha.config; + ) {} + + private get captcha(): CaptchaConfig { + return this.config.captcha.config; } @OnEvent('config.init') @@ -46,36 +57,71 @@ export class CaptchaService { } } - private async verifyCaptchaToken(token: any, ip: string) { - if (typeof token !== 'string' || !token) return false; - + private async verifyCaptchaToken(token: string, ip: string) { const formData = new FormData(); formData.append('secret', this.captcha.turnstile.secret); formData.append('response', token); formData.append('remoteip', ip); - // prevent replay attack - formData.append('idempotency_key', nanoid()); + formData.append('idempotency_key', randomUUID()); const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - const result = await fetch(url, { - body: formData, - method: 'POST', - }); - const outcome = (await result.json()) as { - success: boolean; - hostname: string; - }; + let result: Response; + try { + result = await fetch(url, { + body: formData, + method: 'POST', + signal: AbortSignal.timeout(5000), + }); + } catch { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'turnstile', + result: 'unavailable', + }); + throw new NetworkError('Captcha verification temporarily unavailable'); + } + if (!result.ok) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'turnstile', + result: 'unavailable', + }); + throw new NetworkError('Captcha verification temporarily unavailable'); + } + let parsed: z.SafeParseReturnType< + unknown, + z.infer + >; + try { + parsed = turnstileResponse.safeParse(await result.json()); + } catch { + parsed = turnstileResponse.safeParse(null); + } + if (!parsed.success) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'turnstile', + result: 'unavailable', + }); + throw new NetworkError('Captcha verification temporarily unavailable'); + } + const outcome = parsed.data; if (!outcome.success) return false; + if (outcome.action !== this.captcha.turnstile.action) return false; // skip hostname check in dev mode if (env.dev) return true; // check if the hostname is in the hosts - if (this.config.server.hosts.includes(outcome.hostname)) return true; + if ( + outcome.hostname && + this.config.server.hosts.includes(outcome.hostname) + ) { + return true; + } // check if the hostname is in the host - if (this.config.server.host === outcome.hostname) return true; + if (outcome.hostname && this.config.server.host === outcome.hostname) { + return true; + } this.logger.warn( `Captcha verification failed for hostname: ${outcome.hostname}` @@ -83,7 +129,7 @@ export class CaptchaService { return false; } - private async verifyChallengeResponse(response: any, resource: string) { + private async verifyChallengeResponse(response: string, resource: string) { return verifyChallengeResponse( response, this.captcha.challenge.bits, @@ -91,7 +137,17 @@ export class CaptchaService { ); } - async getChallengeToken() { + async getClientConfig(nativeClient: boolean) { + const provider = nativeClient + ? ('hashcash' as const) + : ('turnstile' as const); + if (provider === 'turnstile') { + return { + provider, + siteKey: this.captcha.turnstile.siteKey, + action: this.captcha.turnstile.action, + }; + } const resource = randomUUID(); const challenge = await this.challenges.create( 'captcha', @@ -100,6 +156,7 @@ export class CaptchaService { ); return { + provider, challenge, resource, }; @@ -109,44 +166,83 @@ export class CaptchaService { try { return validator.parse(credential); } catch { + metrics.auth.counter('captcha_verification').add(1, { + provider: + credential?.provider === 'hashcash' || + credential?.provider === 'turnstile' + ? credential.provider + : 'unknown', + result: 'invalid_credential', + }); throw new CaptchaVerificationFailed('Invalid Credential'); } } async verifyRequest(credential: Credential, req: Request) { - const challenge = credential.challenge; - let resource: string | null = null; - if (typeof challenge === 'string' && challenge) { - resource = await this.challenges.consume('captcha', challenge); - } - - if (resource) { + if (credential.provider === 'hashcash') { + if (!credential.challenge) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'hashcash', + result: 'missing_challenge', + }); + throw new CaptchaVerificationFailed('Missing Challenge'); + } + const resource = await this.challenges.consume( + 'captcha', + credential.challenge + ); + if (!resource) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'hashcash', + result: 'expired_or_replayed', + }); + throw new CaptchaVerificationFailed('Invalid Challenge Response'); + } const isChallengeVerified = await this.verifyChallengeResponse( credential.token, resource ); - - this.logger.debug( - `Challenge: ${challenge}, Resource: ${resource}, Response: ${credential.token}, isChallengeVerified: ${isChallengeVerified}` - ); - if (!isChallengeVerified) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'hashcash', + result: 'invalid_proof', + }); throw new CaptchaVerificationFailed('Invalid Challenge Response'); } + metrics.auth.counter('captcha_verification').add(1, { + provider: 'hashcash', + result: 'success', + }); } else { + if (credential.challenge) { + throw new CaptchaVerificationFailed('Unexpected Challenge'); + } const isTokenVerified = await this.verifyCaptchaToken( credential.token, getRequestClientIp(req) ); if (!isTokenVerified) { + metrics.auth.counter('captcha_verification').add(1, { + provider: 'turnstile', + result: 'failed', + }); throw new CaptchaVerificationFailed('Invalid Captcha Response'); } + metrics.auth.counter('captcha_verification').add(1, { + provider: 'turnstile', + result: 'success', + }); } } private setup() { if (this.config.captcha.enabled) { + if (!this.captcha.turnstile.secret || !this.captcha.turnstile.siteKey) { + throw new Error( + 'Enabled captcha requires Turnstile secret and site key.' + ); + } this.server.enableFeature(ServerFeature.Captcha); } else { this.server.disableFeature(ServerFeature.Captcha); diff --git a/packages/backend/server/src/plugins/captcha/types.ts b/packages/backend/server/src/plugins/captcha/types.ts index 7935914bc3..cc4a2dba47 100644 --- a/packages/backend/server/src/plugins/captcha/types.ts +++ b/packages/backend/server/src/plugins/captcha/types.ts @@ -4,9 +4,12 @@ export interface CaptchaConfig { turnstile: { /** * Cloudflare Turnstile CAPTCHA secret - * default value is demo api key, witch always return success */ secret: string; + /** Public Turnstile widget site key. */ + siteKey: string; + /** Expected action bound to the authentication widget and Siteverify response. */ + action: string; }; challenge: { /** diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 6350dd9260..aca8ea98ac 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -18,14 +18,10 @@ import { URLHelper, UseNamedGuard, } from '../../base'; -import { - OAuthCallbackBodySchema, - OAuthPreflightBodySchema, - Public, - SessionIssuer, -} from '../../core/auth'; +import { Public, SessionIssuer } from '../../core/auth'; import { OAuthProviderName } from './config'; import { OAuthProviderFactory } from './factory'; +import { OAuthCallbackBodySchema, OAuthPreflightBodySchema } from './input'; import { OAuthService } from './service'; @Controller('/api/oauth') @@ -48,6 +44,16 @@ export class OAuthController { if (fields.has('client_nonce')) { throw new MissingOauthQueryParameter({ name: 'client_nonce' }); } + if (fields.has('client')) { + throw new ActionForbidden(); + } + if (fields.has('provider')) { + const provider = + body && typeof body === 'object' && 'provider' in body + ? String(body.provider) + : ''; + throw new UnknownOauthProvider({ name: provider }); + } throw new MissingOauthQueryParameter({ name: 'provider' }); } diff --git a/packages/backend/server/src/plugins/oauth/input.ts b/packages/backend/server/src/plugins/oauth/input.ts new file mode 100644 index 0000000000..1e1012dfe7 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/input.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +export const OAuthProviderSchema = z.enum([ + 'Google', + 'GitHub', + 'Apple', + 'OIDC', +]); + +export const OAuthClientSchema = z.enum([ + 'web', + 'affine', + 'affine-canary', + 'affine-beta', + 'affine-dev', +]); + +export const OAuthPreflightBodySchema = z + .object({ + provider: OAuthProviderSchema, + redirect_uri: z + .string() + .min(1) + .max(2048) + .nullish() + .transform(value => value ?? undefined), + client: OAuthClientSchema, + client_nonce: z.string().min(1).max(512), + }) + .strict(); + +export const OAuthCallbackBodySchema = z + .object({ + code: z.string().min(1).max(4096), + state: z.string().min(1).max(16_384), + client_nonce: z + .string() + .min(1) + .max(512) + .nullish() + .transform(value => value ?? undefined), + // Apple includes this JSON field on the first form_post callback. + user: z.string().max(16_384).optional(), + }) + .strict(); + +export const OAuthStateEnvelopeSchema = z + .object({ + state: z.string().uuid(), + provider: OAuthProviderSchema, + client: OAuthClientSchema, + flow: z.enum(['popup', 'redirect']).optional(), + pkce: z + .object({ + codeChallenge: z.string().min(1).max(512), + codeChallengeMethod: z.literal('S256'), + }) + .strict() + .optional(), + }) + .strict(); diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts index 3da3afb66c..10aebc2e60 100644 --- a/packages/backend/server/src/plugins/oauth/service.ts +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -15,12 +15,12 @@ import { import { AuthChallengeStore, AuthService, - OAuthStateEnvelopeSchema, type VerifiedIdentity, } from '../../core/auth'; import { Models } from '../../models'; import { OAuthProviderName } from './config'; import { OAuthProviderFactory } from './factory'; +import { OAuthStateEnvelopeSchema } from './input'; import { OAuthAccount, Tokens } from './providers/def'; import { OAuthPkceChallenge, OAuthState } from './types'; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index cd8f8b049c..778bffc3c8 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -305,6 +305,16 @@ type AudioSliceManifestItemType { startSec: Float! } +type AuthSigningKeyType { + canDelete: Boolean! + createdAt: DateTime + id: String! + retiredAt: DateTime + source: String! + status: String! + verifyUntil: DateTime +} + type BlobNotFoundDataType { blobId: String! spaceId: String! @@ -995,10 +1005,15 @@ union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarP enum ErrorNames { ACCESS_DENIED + ACCESS_TOKEN_EXPIRED + ACCESS_TOKEN_INVALID ACTION_FORBIDDEN ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE ALREADY_IN_SPACE AUTHENTICATION_REQUIRED + AUTH_SESSION_EXPIRED + AUTH_SESSION_REVOKED + AUTH_SESSION_TEMPORARILY_UNAVAILABLE BAD_REQUEST BLOB_INVALID BLOB_NOT_FOUND @@ -1105,6 +1120,8 @@ enum ErrorNames { OWNER_CAN_NOT_LEAVE_WORKSPACE PASSWORD_REQUIRED QUERY_TOO_LONG + REFRESH_TOKEN_INVALID + REFRESH_TOKEN_REUSED REPLY_NOT_FOUND RESPONSE_TOO_LARGE_ERROR RUNTIME_CONFIG_NOT_FOUND @@ -1651,6 +1668,7 @@ type Mutation { createWorkspaceByokLocalLease(input: CreateWorkspaceByokLocalLeaseInput!): CreateWorkspaceByokLocalLeaseResultType! deactivateLicense(workspaceId: String!): Boolean! deleteAccount: DeleteAccount! + deleteAuthSigningKey(id: String!): [AuthSigningKeyType!]! deleteBlob(hash: String @deprecated(reason: "use parameter [key]"), key: String, permanently: Boolean! = false, workspaceId: String!): Boolean! """Delete a comment""" @@ -1734,6 +1752,7 @@ type Mutation { revokeMember(userId: String!, workspaceId: String!): Boolean! revokePublicDoc(docId: String!, workspaceId: String!): DocType! revokeUserAccessToken(id: String!): Boolean! + rotateAuthSigningKey(expectedActiveKeyId: String!): [AuthSigningKeyType!]! sendChangeEmail(callbackUrl: String!): Boolean! sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! @@ -2004,6 +2023,7 @@ type Query { """get the whole app configuration""" appConfig: JSONObject! + authSigningKeys: [AuthSigningKeyType!]! """Get current user""" currentUser: UserType @@ -2722,7 +2742,7 @@ type UserType { """Get user settings""" settings: UserSettingsType! subscriptions: [SubscriptionType!]! - token: tokenType! @deprecated(reason: "use native session exchange instead") + token: tokenType! @deprecated(reason: "use auth session exchange instead") } type ValidationErrorDataType { diff --git a/packages/common/auth/package.json b/packages/common/auth/package.json new file mode 100644 index 0000000000..3678bea109 --- /dev/null +++ b/packages/common/auth/package.json @@ -0,0 +1,12 @@ +{ + "name": "@affine/auth", + "private": true, + "type": "module", + "version": "0.27.0", + "exports": { + ".": "./src/index.ts" + }, + "devDependencies": { + "vitest": "^4.1.8" + } +} diff --git a/packages/common/auth/src/index.ts b/packages/common/auth/src/index.ts new file mode 100644 index 0000000000..9d5a198fde --- /dev/null +++ b/packages/common/auth/src/index.ts @@ -0,0 +1 @@ +export * from './token-broker'; diff --git a/packages/common/auth/src/token-broker.spec.ts b/packages/common/auth/src/token-broker.spec.ts new file mode 100644 index 0000000000..6bea9e088d --- /dev/null +++ b/packages/common/auth/src/token-broker.spec.ts @@ -0,0 +1,409 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + AuthSessionError, + AuthTokenBroker, + type AuthTokenPair, + type AuthTokenResponse, + type AuthTokenStorage, + createRealtimeAuthAdapter, + createRequestAuthAdapter, + withAuthRetry, +} from './token-broker'; + +const now = Date.parse('2026-07-11T00:00:00.000Z'); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, reject, resolve }; +} + +function response(sequence: number): AuthTokenResponse { + return { + tokenType: 'Bearer', + accessToken: `access-${sequence}`, + expiresIn: 900, + refreshToken: `refresh-${sequence}`, + refreshExpiresAt: '2026-08-11T00:00:00.000Z', + session: { + id: 'session-1', + absoluteExpiresAt: '2027-01-11T00:00:00.000Z', + }, + }; +} + +function pair(sequence: number, accessExpiresAt: number): AuthTokenPair { + return { + version: 1, + ...response(sequence), + accessExpiresAt: new Date(accessExpiresAt).toISOString(), + }; +} + +function setup(initial: AuthTokenPair | null) { + let persisted = initial; + const storage: AuthTokenStorage = { + load: vi.fn(async () => persisted), + save: vi.fn(async next => { + persisted = next; + }), + clear: vi.fn(async () => { + persisted = null; + }), + }; + const transport = { refresh: vi.fn(async () => response(2)) }; + return { + broker: new AuthTokenBroker(storage, transport, { + now: () => now, + retryDelays: [], + }), + storage, + transport, + persisted: () => persisted, + }; +} + +describe('AuthTokenBroker', () => { + test('proactively refreshes and atomically persists before publishing', async () => { + const { broker, storage, persisted } = setup(pair(1, now + 30_000)); + const states: string[] = []; + broker.observeAuthState(state => states.push(state.status)); + + await expect(broker.getValidAccessToken()).resolves.toBe('access-2'); + expect(storage.save).toHaveBeenCalledOnce(); + expect(persisted()?.accessToken).toBe('access-2'); + expect(states).toEqual([ + 'initializing', + 'authenticated', + 'refreshing', + 'authenticated', + ]); + }); + + test('collapses concurrent refresh into one transport request', async () => { + const { broker, transport } = setup(pair(1, now + 30_000)); + const tokens = await Promise.all( + Array.from({ length: 100 }, () => broker.getValidAccessToken()) + ); + + expect(new Set(tokens)).toEqual(new Set(['access-2'])); + expect(transport.refresh).toHaveBeenCalledOnce(); + }); + + test('keeps credentials for transient failures', async () => { + const { broker, storage, transport } = setup(pair(1, now + 30_000)); + transport.refresh.mockRejectedValueOnce({ + code: 'NETWORK_ERROR', + config: { body: 'refresh-1' }, + }); + const states: unknown[] = []; + broker.observeAuthState(state => states.push(state)); + + const error = await broker.getValidAccessToken().catch(error => error); + expect(error).toMatchObject({ + transient: true, + }); + expect(JSON.stringify(error)).not.toContain('refresh-1'); + expect(JSON.stringify(states)).not.toContain('refresh-1'); + expect(storage.clear).not.toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + status: 'offline-authenticated', + code: 'NETWORK_ERROR', + }); + }); + + test('retries transient refresh failures with bounded jittered backoff', async () => { + const { storage, transport } = setup(pair(1, now + 30_000)); + transport.refresh + .mockRejectedValueOnce(new TypeError('offline')) + .mockResolvedValueOnce(response(2)); + const sleep = vi.fn(async () => {}); + const broker = new AuthTokenBroker(storage, transport, { + now: () => now, + random: () => 0.5, + retryDelays: [250], + sleep, + }); + + await expect(broker.getValidAccessToken()).resolves.toBe('access-2'); + expect(transport.refresh).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(250); + }); + + test('clears credentials only for permanent auth failures', async () => { + const { broker, storage, transport } = setup(pair(1, now + 30_000)); + transport.refresh.mockRejectedValueOnce({ code: 'AUTH_SESSION_REVOKED' }); + + await expect(broker.getValidAccessToken()).rejects.toMatchObject({ + code: 'AUTH_SESSION_REVOKED', + transient: false, + }); + expect(storage.clear).toHaveBeenCalledOnce(); + }); + + test('retries a request once only for access-token expiration', async () => { + const { broker, transport } = setup(pair(1, now + 600_000)); + const request = vi + .fn<(token: string) => Promise>() + .mockRejectedValueOnce({ code: 'ACCESS_TOKEN_EXPIRED' }) + .mockResolvedValueOnce('ok'); + + await expect(withAuthRetry(broker, request)).resolves.toBe('ok'); + expect(request).toHaveBeenCalledTimes(2); + expect(transport.refresh).toHaveBeenCalledOnce(); + }); + + test('does not retry permission failures', async () => { + const { broker, transport } = setup(pair(1, now + 600_000)); + await expect( + withAuthRetry(broker, async () => { + throw new AuthSessionError('FORBIDDEN', false); + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + createRealtimeAuthAdapter(broker).recover({ code: 'FORBIDDEN' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(transport.refresh).not.toHaveBeenCalled(); + }); + + test('serializes initialization before set and clear mutations', async () => { + const loaded = deferred(); + let persisted: AuthTokenPair | null = pair(1, now + 600_000); + const storage: AuthTokenStorage = { + load: vi.fn(() => loaded.promise), + save: vi.fn(async next => { + persisted = next; + }), + clear: vi.fn(async () => { + persisted = null; + }), + }; + const broker = new AuthTokenBroker( + storage, + { refresh: vi.fn() }, + { + now: () => now, + } + ); + const setting = broker.set(response(2)); + loaded.resolve(persisted); + await setting; + + expect(await broker.getValidAccessToken(0)).toBe('access-2'); + await broker.clear('logout'); + expect(persisted).toBeNull(); + expect(await broker.getValidAccessToken()).toBeNull(); + }); + + test('does not let delayed initialization undo clear', async () => { + const loaded = deferred(); + let persisted: AuthTokenPair | null = pair(1, now + 600_000); + const storage: AuthTokenStorage = { + load: vi.fn(() => loaded.promise), + save: vi.fn(), + clear: vi.fn(async () => { + persisted = null; + }), + }; + const broker = new AuthTokenBroker( + storage, + { refresh: vi.fn() }, + { + now: () => now, + } + ); + const clearing = broker.clear('logout'); + loaded.resolve(persisted); + await clearing; + + expect(persisted).toBeNull(); + expect(await broker.getValidAccessToken()).toBeNull(); + }); + + test('retries pending secure-store persistence without rotating again', async () => { + const { broker, storage, transport, persisted } = setup( + pair(1, now + 30_000) + ); + vi.mocked(storage.save).mockRejectedValueOnce(new Error('locked')); + + await expect(broker.getValidAccessToken()).rejects.toMatchObject({ + code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE', + }); + const recovered = await broker.refresh('storage-retry'); + expect(recovered).toMatchObject({ accessToken: 'access-2' }); + expect(JSON.stringify(recovered)).not.toContain('refresh-2'); + expect(transport.refresh).toHaveBeenCalledOnce(); + expect(persisted()?.accessToken).toBe('access-2'); + }); + + test('does not resurrect a session when clear races refresh', async () => { + const { broker, transport, persisted } = setup(pair(1, now + 600_000)); + await broker.getValidAccessToken(0); + const rotated = deferred(); + transport.refresh.mockReturnValueOnce(rotated.promise); + const refreshing = broker.refresh('manual'); + await Promise.resolve(); + const clearing = broker.clear('logout'); + rotated.resolve(response(2)); + + await expect(refreshing).rejects.toMatchObject({ + code: 'AUTH_OPERATION_CANCELLED', + }); + await clearing; + expect(persisted()).toBeNull(); + expect(await broker.getValidAccessToken()).toBeNull(); + }); + + test('isolates observers and rejects malformed persisted records', async () => { + const { broker } = setup(pair(1, now + 600_000)); + const states: unknown[] = []; + broker.observeAuthState(() => { + throw new Error('observer failure'); + }); + broker.observeAuthState(state => states.push(state)); + await expect(broker.getValidAccessToken()).resolves.toBe('access-1'); + expect(JSON.stringify(states)).not.toContain('refresh-1'); + + const storage: AuthTokenStorage = { + load: vi.fn(async () => ({ version: 2 }) as never), + save: vi.fn(), + clear: vi.fn(), + }; + const malformed = new AuthTokenBroker(storage, { refresh: vi.fn() }); + await expect(malformed.getValidAccessToken()).resolves.toBeNull(); + expect(storage.clear).toHaveBeenCalledOnce(); + }); + + test('shares one refresh across HTTP replay and realtime recovery', async () => { + const { broker, transport } = setup(pair(1, now + 600_000)); + const rotated = deferred(); + transport.refresh.mockReturnValueOnce(rotated.promise); + const request = vi + .fn<(token: string) => Promise>() + .mockRejectedValueOnce({ code: 'ACCESS_TOKEN_EXPIRED' }) + .mockResolvedValueOnce('ok'); + const realtime = createRealtimeAuthAdapter(broker); + const http = createRequestAuthAdapter(broker).execute(request); + const socket = realtime.recover({ code: 'ACCESS_TOKEN_EXPIRED' }); + await Promise.resolve(); + rotated.resolve(response(2)); + + await expect(Promise.all([http, socket])).resolves.toEqual([ + 'ok', + 'access-2', + ]); + expect(transport.refresh).toHaveBeenCalledOnce(); + }); + + test.each([ + { name: 'permanent', error: { code: 'AUTH_SESSION_REVOKED' } }, + { name: 'transient', error: new TypeError('offline') }, + ])('ignores a stale $name refresh failure after a new login', async item => { + const { broker, transport, persisted } = setup(pair(1, now + 600_000)); + await broker.getValidAccessToken(0); + const oldRefresh = deferred(); + transport.refresh.mockReturnValueOnce(oldRefresh.promise); + const refreshing = broker.refresh('manual'); + await Promise.resolve(); + await broker.set(response(3)); + oldRefresh.reject(item.error); + + await expect(refreshing).rejects.toMatchObject({ + code: 'AUTH_OPERATION_CANCELLED', + }); + expect(await broker.getValidAccessToken(0)).toBe('access-3'); + expect(persisted()?.accessToken).toBe('access-3'); + }); + + test('retries initialization after a transient storage failure', async () => { + const stored = pair(1, now + 600_000); + const storage: AuthTokenStorage = { + load: vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('locked')) + .mockResolvedValueOnce(stored), + save: vi.fn(), + clear: vi.fn(), + }; + const broker = new AuthTokenBroker( + storage, + { refresh: vi.fn() }, + { + now: () => now, + } + ); + const states: string[] = []; + broker.observeAuthState(state => states.push(state.status)); + + await expect(broker.getValidAccessToken()).rejects.toMatchObject({ + code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE', + transient: true, + }); + await expect(broker.getValidAccessToken()).resolves.toBe('access-1'); + expect(storage.load).toHaveBeenCalledTimes(2); + expect(states).toEqual(['initializing', 'authenticated']); + }); + + test.each([0, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_VALUE])( + 'rejects malformed expiresIn %s before persistence', + async expiresIn => { + const { broker, storage, transport } = setup(pair(1, now + 30_000)); + transport.refresh.mockResolvedValueOnce({ ...response(2), expiresIn }); + + await expect(broker.getValidAccessToken()).rejects.toMatchObject({ + code: 'AUTH_TOKEN_RESPONSE_INVALID', + }); + expect(storage.save).not.toHaveBeenCalled(); + } + ); + + test('normalizes clear storage failures after clearing memory', async () => { + const { broker, storage } = setup(pair(1, now + 600_000)); + await broker.getValidAccessToken(0); + vi.mocked(storage.clear).mockRejectedValueOnce(new Error('locked')); + + await expect(broker.clear('logout')).rejects.toMatchObject({ + code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE', + }); + await expect(broker.getValidAccessToken()).resolves.toBeNull(); + }); + + test('revokes captured credential even when local clear fails', async () => { + const { broker, storage } = setup(pair(1, now + 600_000)); + await broker.getValidAccessToken(0); + vi.mocked(storage.clear).mockRejectedValueOnce(new Error('locked')); + const revoke = vi.fn(async () => {}); + + await expect(broker.revoke('logout', revoke)).rejects.toMatchObject({ + code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE', + }); + expect(revoke).toHaveBeenCalledWith('refresh-1'); + await expect(broker.getValidAccessToken()).resolves.toBeNull(); + }); + + test('initializes when the first consumer only observes state', async () => { + const { broker, storage } = setup(pair(1, now + 600_000)); + const states: string[] = []; + broker.observeAuthState(state => states.push(state.status)); + await vi.waitFor(() => expect(states.at(-1)).toBe('authenticated')); + + expect(storage.load).toHaveBeenCalledOnce(); + expect(states).toEqual(['initializing', 'authenticated']); + }); + + test('redacts unknown external error codes', async () => { + const { broker, transport } = setup(pair(1, now + 30_000)); + transport.refresh.mockRejectedValueOnce({ + code: 'secret-refresh-1', + }); + + await expect(broker.getValidAccessToken()).rejects.toMatchObject({ + code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', + }); + }); +}); diff --git a/packages/common/auth/src/token-broker.ts b/packages/common/auth/src/token-broker.ts new file mode 100644 index 0000000000..b5928806d8 --- /dev/null +++ b/packages/common/auth/src/token-broker.ts @@ -0,0 +1,464 @@ +export const PERMANENT_AUTH_CODES = new Set([ + 'ACCESS_TOKEN_INVALID', + 'AUTH_SESSION_EXPIRED', + 'AUTH_SESSION_REVOKED', + 'REFRESH_TOKEN_INVALID', + 'REFRESH_TOKEN_REUSED', + 'UNSUPPORTED_CLIENT_VERSION', +]); +const PUBLIC_AUTH_CODES = new Set([ + ...PERMANENT_AUTH_CODES, + 'ACCESS_TOKEN_EXPIRED', + 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', + 'AUTH_TOKEN_RESPONSE_INVALID', + 'AUTH_TOKEN_STORAGE_UNAVAILABLE', + 'FORBIDDEN', + 'NETWORK_ERROR', + 'TOO_MANY_REQUESTS', +]); +const SAFE_AUTH_CODES = new Set([ + ...PUBLIC_AUTH_CODES, + 'AUTH_OPERATION_CANCELLED', + 'AUTH_SESSION_EMPTY', +]); + +export interface AuthTokenPair { + version: 1; + tokenType: 'Bearer'; + accessToken: string; + accessExpiresAt: string; + refreshToken: string; + refreshExpiresAt: string; + session: { + id: string; + absoluteExpiresAt: string; + }; +} + +export interface AuthTokenResponse { + tokenType: 'Bearer'; + accessToken: string; + expiresIn: number; + refreshToken: string; + refreshExpiresAt: string; + session: AuthTokenPair['session']; +} + +export interface AuthSessionSnapshot { + accessExpiresAt: string; + refreshExpiresAt: string; + session: AuthTokenPair['session']; +} + +export interface AuthAccessToken extends AuthSessionSnapshot { + accessToken: string; +} + +export type AuthState = + | { status: 'initializing' } + | { status: 'empty' } + | { status: 'authenticated'; session: AuthSessionSnapshot } + | { status: 'refreshing'; session: AuthSessionSnapshot; reason: string } + | { + status: 'offline-authenticated'; + session: AuthSessionSnapshot; + code: string; + } + | { status: 'revoked'; code: string }; + +export interface AuthTokenStorage { + load(): Promise; + save(pair: AuthTokenPair): Promise; + clear(): Promise; +} + +export interface AuthTokenTransport { + refresh(refreshToken: string): Promise; +} + +export interface AuthTokenBrokerContract { + getValidAccessToken(minValidity?: number): Promise; + refresh(reason: string): Promise; + clear(reason: string): Promise; + observeAuthState(listener: (state: AuthState) => void): () => void; +} + +export interface AuthTokenBrokerOptions { + now?: () => number; + random?: () => number; + retryDelays?: number[]; + sleep?: (delay: number) => Promise; +} + +export class AuthSessionError extends Error { + constructor( + readonly code: string, + readonly transient: boolean + ) { + super(code); + } +} + +export class AuthTokenBroker implements AuthTokenBrokerContract { + private pair: AuthTokenPair | null = null; + private initialized: Promise | null = null; + private refreshPromise: Promise | null = null; + private pending: { pair: AuthTokenPair; epoch: number } | null = null; + private mutationEpoch = 0; + private storageMutation: Promise = Promise.resolve(); + private readonly listeners = new Set<(state: AuthState) => void>(); + private state: AuthState = { status: 'initializing' }; + private readonly now: () => number; + private readonly random: () => number; + private readonly retryDelays: number[]; + private readonly sleep: (delay: number) => Promise; + + constructor( + private readonly storage: AuthTokenStorage, + private readonly transport: AuthTokenTransport, + options: AuthTokenBrokerOptions = {} + ) { + this.now = options.now ?? Date.now; + this.random = options.random ?? Math.random; + this.retryDelays = options.retryDelays ?? [250, 1000]; + this.sleep = + options.sleep ?? + (delay => new Promise(resolve => setTimeout(resolve, delay))); + } + + observeAuthState(listener: (state: AuthState) => void) { + this.listeners.add(listener); + try { + listener(this.state); + } catch { + // Observers cannot participate in credential lifecycle decisions. + } + void this.initialize().catch(() => {}); + return () => { + this.listeners.delete(listener); + }; + } + + async set(response: AuthTokenResponse) { + await this.initialize(); + const epoch = ++this.mutationEpoch; + const pair = this.toPair(response); + this.pending = { pair, epoch }; + return this.toAccessToken(await this.persistPending()); + } + + async getValidAccessToken(minValidity = 60_000) { + await this.initialize(); + if (!this.pair) return null; + if (Date.parse(this.pair.accessExpiresAt) - this.now() <= minValidity) { + await this.refresh('proactive'); + } + return this.pair?.accessToken ?? null; + } + + async refresh(reason: string) { + await this.initialize(); + if (!this.pair) throw new AuthSessionError('AUTH_SESSION_EMPTY', false); + if (!this.refreshPromise) { + this.refreshPromise = ( + this.pending ? this.persistPending() : this.performRefresh(reason) + ).finally(() => { + this.refreshPromise = null; + }); + } + return this.toAccessToken(await this.refreshPromise); + } + + async clear(_reason: string) { + await this.initialize(); + ++this.mutationEpoch; + this.pending = null; + let failed = false; + try { + await this.mutateStorage(() => this.storage.clear()); + } catch { + failed = true; + } finally { + this.pair = null; + this.publish({ status: 'empty' }); + } + if (failed) { + throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true); + } + } + + async revoke( + _reason: string, + revokeToken: (refreshToken: string) => Promise + ) { + await this.initialize(); + ++this.mutationEpoch; + this.pending = null; + const refreshToken = this.pair?.refreshToken; + let storageError: unknown; + try { + await this.mutateStorage(() => this.storage.clear()); + } catch (error) { + storageError = error; + } finally { + this.pair = null; + this.publish({ status: 'empty' }); + } + if (refreshToken) await revokeToken(refreshToken); + if (storageError) { + throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true); + } + } + + private async initialize() { + if (!this.initialized) { + const attempt = Promise.resolve() + .then(() => this.storage.load()) + .then(async pair => { + if (pair && !isAuthTokenPair(pair)) { + await this.mutateStorage(() => this.storage.clear()); + pair = null; + } + this.pair = pair; + this.publish( + pair + ? { status: 'authenticated', session: this.toSnapshot(pair) } + : { status: 'empty' } + ); + }) + .catch(() => { + throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true); + }); + this.initialized = attempt; + try { + await attempt; + } catch (error) { + if (this.initialized === attempt) this.initialized = null; + throw error; + } + return; + } + await this.initialized; + } + + private async performRefresh(reason: string) { + const current = this.pair; + if (!current) throw new AuthSessionError('AUTH_SESSION_EMPTY', false); + const epoch = this.mutationEpoch; + this.publish({ + status: 'refreshing', + session: this.toSnapshot(current), + reason, + }); + try { + const pair = this.toPair(await this.requestRefresh(current.refreshToken)); + if (epoch !== this.mutationEpoch) { + throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true); + } + this.pending = { pair, epoch }; + return await this.persistPending(); + } catch (error) { + const classified = classifyAuthError(error); + if ( + classified.code === 'AUTH_OPERATION_CANCELLED' || + epoch !== this.mutationEpoch + ) { + throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true); + } + if (!classified.transient) { + try { + await this.mutateStorage(() => this.storage.clear()); + } catch { + // The in-memory credential must still become unusable immediately. + } finally { + this.pair = null; + this.publish({ status: 'revoked', code: classified.code }); + } + } else { + this.publish({ + status: 'offline-authenticated', + session: this.toSnapshot(current), + code: classified.code, + }); + } + throw classified; + } + } + + private async persistPending() { + const pending = this.pending; + if (!pending) throw new AuthSessionError('AUTH_SESSION_EMPTY', false); + try { + await this.mutateStorage(() => this.storage.save(pending.pair)); + } catch { + throw new AuthSessionError('AUTH_TOKEN_STORAGE_UNAVAILABLE', true); + } + if (pending.epoch !== this.mutationEpoch) { + throw new AuthSessionError('AUTH_OPERATION_CANCELLED', true); + } + this.pending = null; + this.pair = pending.pair; + this.publish({ + status: 'authenticated', + session: this.toSnapshot(pending.pair), + }); + return pending.pair; + } + + private async mutateStorage(operation: () => Promise) { + const result = this.storageMutation.then(operation, operation); + this.storageMutation = result.then( + () => {}, + () => {} + ); + return await result; + } + + private async requestRefresh(refreshToken: string) { + for (let attempt = 0; ; attempt++) { + try { + return await this.transport.refresh(refreshToken); + } catch (error) { + const classified = classifyAuthError(error); + const delay = this.retryDelays[attempt]; + if (!classified.transient || delay === undefined) throw classified; + await this.sleep(delay * (0.75 + this.random() * 0.5)); + } + } + } + + private toPair(response: AuthTokenResponse): AuthTokenPair { + const accessExpiresAt = this.now() + response.expiresIn * 1000; + if ( + !Number.isFinite(response.expiresIn) || + response.expiresIn <= 0 || + !Number.isFinite(accessExpiresAt) || + Math.abs(accessExpiresAt) > 8.64e15 + ) { + throw new AuthSessionError('AUTH_TOKEN_RESPONSE_INVALID', true); + } + const pair: AuthTokenPair = { + version: 1, + tokenType: response.tokenType, + accessToken: response.accessToken, + accessExpiresAt: new Date(accessExpiresAt).toISOString(), + refreshToken: response.refreshToken, + refreshExpiresAt: response.refreshExpiresAt, + session: response.session, + }; + if (!isAuthTokenPair(pair)) { + throw new AuthSessionError('AUTH_TOKEN_RESPONSE_INVALID', true); + } + return pair; + } + + private toSnapshot(pair: AuthTokenPair): AuthSessionSnapshot { + return { + accessExpiresAt: pair.accessExpiresAt, + refreshExpiresAt: pair.refreshExpiresAt, + session: { ...pair.session }, + }; + } + + private toAccessToken(pair: AuthTokenPair): AuthAccessToken { + return { + ...this.toSnapshot(pair), + accessToken: pair.accessToken, + }; + } + + private publish(state: AuthState) { + this.state = state; + for (const listener of this.listeners) { + try { + listener(state); + } catch { + // Observers cannot participate in credential lifecycle decisions. + } + } + } +} + +export function classifyAuthError(error: unknown) { + if (error instanceof AuthSessionError) { + return SAFE_AUTH_CODES.has(error.code) + ? error + : new AuthSessionError('AUTH_SESSION_TEMPORARILY_UNAVAILABLE', true); + } + const code = + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' + ? error.code + : 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE'; + const publicCode = PUBLIC_AUTH_CODES.has(code) + ? code + : 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE'; + return new AuthSessionError( + publicCode, + !PERMANENT_AUTH_CODES.has(publicCode) + ); +} + +export async function withAuthRetry( + broker: AuthTokenBrokerContract, + request: (accessToken: string) => Promise +) { + const token = await broker.getValidAccessToken(); + if (!token) throw new AuthSessionError('AUTH_SESSION_EMPTY', false); + try { + return await request(token); + } catch (error) { + const classified = classifyAuthError(error); + if (classified.code !== 'ACCESS_TOKEN_EXPIRED') throw classified; + const pair = await broker.refresh('access-token-expired'); + return await request(pair.accessToken); + } +} + +export function createRealtimeAuthAdapter(broker: AuthTokenBrokerContract) { + return { + getAccessToken: () => broker.getValidAccessToken(120_000), + recover: async (error: unknown) => { + const classified = classifyAuthError(error); + if (classified.code !== 'ACCESS_TOKEN_EXPIRED') throw classified; + return (await broker.refresh('realtime-access-token-expired')) + .accessToken; + }, + }; +} + +export function createRequestAuthAdapter(broker: AuthTokenBrokerContract) { + return { + execute: (request: (accessToken: string) => Promise) => + withAuthRetry(broker, request), + }; +} + +export function createWorkerAuthAdapter(broker: AuthTokenBrokerContract) { + return { + getAccessToken: () => broker.getValidAccessToken(60_000), + }; +} + +export function isAuthTokenPair(value: unknown): value is AuthTokenPair { + if (!value || typeof value !== 'object') return false; + const pair = value as Partial; + return ( + pair.version === 1 && + pair.tokenType === 'Bearer' && + typeof pair.accessToken === 'string' && + pair.accessToken.length > 0 && + typeof pair.refreshToken === 'string' && + pair.refreshToken.length > 0 && + typeof pair.accessExpiresAt === 'string' && + Number.isFinite(Date.parse(pair.accessExpiresAt)) && + typeof pair.refreshExpiresAt === 'string' && + Number.isFinite(Date.parse(pair.refreshExpiresAt)) && + !!pair.session && + typeof pair.session.id === 'string' && + typeof pair.session.absoluteExpiresAt === 'string' && + Number.isFinite(Date.parse(pair.session.absoluteExpiresAt)) + ); +} diff --git a/packages/common/auth/tsconfig.json b/packages/common/auth/tsconfig.json new file mode 100644 index 0000000000..76d5d68d61 --- /dev/null +++ b/packages/common/auth/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [] +} diff --git a/packages/common/graphql/src/graphql/admin/auth-signing-keys.gql b/packages/common/graphql/src/graphql/admin/auth-signing-keys.gql new file mode 100644 index 0000000000..91da5c4cc4 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/auth-signing-keys.gql @@ -0,0 +1,11 @@ +query authSigningKeys { + authSigningKeys { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +} diff --git a/packages/common/graphql/src/graphql/admin/delete-auth-signing-key.gql b/packages/common/graphql/src/graphql/admin/delete-auth-signing-key.gql new file mode 100644 index 0000000000..5c0fcea9b4 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/delete-auth-signing-key.gql @@ -0,0 +1,11 @@ +mutation deleteAuthSigningKey($id: String!) { + deleteAuthSigningKey(id: $id) { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +} diff --git a/packages/common/graphql/src/graphql/admin/rotate-auth-signing-key.gql b/packages/common/graphql/src/graphql/admin/rotate-auth-signing-key.gql new file mode 100644 index 0000000000..2d48919f56 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/rotate-auth-signing-key.gql @@ -0,0 +1,11 @@ +mutation rotateAuthSigningKey($expectedActiveKeyId: String!) { + rotateAuthSigningKey(expectedActiveKeyId: $expectedActiveKeyId) { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index db28d1347d..3efee8dc26 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -396,6 +396,22 @@ export const adminWorkspacesCountQuery = { }`, }; +export const authSigningKeysQuery = { + id: 'authSigningKeysQuery' as const, + op: 'authSigningKeys', + query: `query authSigningKeys { + authSigningKeys { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +}`, +}; + export const createChangePasswordUrlMutation = { id: 'createChangePasswordUrlMutation' as const, op: 'createChangePasswordUrl', @@ -422,6 +438,22 @@ export const createUserMutation = { }`, }; +export const deleteAuthSigningKeyMutation = { + id: 'deleteAuthSigningKeyMutation' as const, + op: 'deleteAuthSigningKey', + query: `mutation deleteAuthSigningKey($id: String!) { + deleteAuthSigningKey(id: $id) { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +}`, +}; + export const deleteUserMutation = { id: 'deleteUserMutation' as const, op: 'deleteUser', @@ -508,6 +540,22 @@ export const listUsersQuery = { }`, }; +export const rotateAuthSigningKeyMutation = { + id: 'rotateAuthSigningKeyMutation' as const, + op: 'rotateAuthSigningKey', + query: `mutation rotateAuthSigningKey($expectedActiveKeyId: String!) { + rotateAuthSigningKey(expectedActiveKeyId: $expectedActiveKeyId) { + id + status + source + createdAt + retiredAt + verifyUntil + canDelete + } +}`, +}; + export const sendTestEmailMutation = { id: 'sendTestEmailMutation' as const, op: 'sendTestEmail', @@ -1950,7 +1998,7 @@ export const getCurrentUserQuery = { } } }`, - deprecations: ["'token' is deprecated: use native session exchange instead"], + deprecations: ["'token' is deprecated: use auth session exchange instead"], }; export const getDocCreatedByUpdatedByListQuery = { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index f4e0b2ea38..da4c5027d0 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -365,6 +365,17 @@ export interface AudioSliceManifestItemType { startSec: Scalars['Float']['output']; } +export interface AuthSigningKeyType { + __typename?: 'AuthSigningKeyType'; + canDelete: Scalars['Boolean']['output']; + createdAt: Maybe; + id: Scalars['String']['output']; + retiredAt: Maybe; + source: Scalars['String']['output']; + status: Scalars['String']['output']; + verifyUntil: Maybe; +} + export interface BlobNotFoundDataType { __typename?: 'BlobNotFoundDataType'; blobId: Scalars['String']['output']; @@ -1222,10 +1233,15 @@ export type ErrorDataUnion = export enum ErrorNames { ACCESS_DENIED = 'ACCESS_DENIED', + ACCESS_TOKEN_EXPIRED = 'ACCESS_TOKEN_EXPIRED', + ACCESS_TOKEN_INVALID = 'ACCESS_TOKEN_INVALID', ACTION_FORBIDDEN = 'ACTION_FORBIDDEN', ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE = 'ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE', ALREADY_IN_SPACE = 'ALREADY_IN_SPACE', AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED', + AUTH_SESSION_EXPIRED = 'AUTH_SESSION_EXPIRED', + AUTH_SESSION_REVOKED = 'AUTH_SESSION_REVOKED', + AUTH_SESSION_TEMPORARILY_UNAVAILABLE = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', BAD_REQUEST = 'BAD_REQUEST', BLOB_INVALID = 'BLOB_INVALID', BLOB_NOT_FOUND = 'BLOB_NOT_FOUND', @@ -1332,6 +1348,8 @@ export enum ErrorNames { OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE', PASSWORD_REQUIRED = 'PASSWORD_REQUIRED', QUERY_TOO_LONG = 'QUERY_TOO_LONG', + REFRESH_TOKEN_INVALID = 'REFRESH_TOKEN_INVALID', + REFRESH_TOKEN_REUSED = 'REFRESH_TOKEN_REUSED', REPLY_NOT_FOUND = 'REPLY_NOT_FOUND', RESPONSE_TOO_LARGE_ERROR = 'RESPONSE_TOO_LARGE_ERROR', RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND', @@ -1861,6 +1879,7 @@ export interface Mutation { createWorkspaceByokLocalLease: CreateWorkspaceByokLocalLeaseResultType; deactivateLicense: Scalars['Boolean']['output']; deleteAccount: DeleteAccount; + deleteAuthSigningKey: Array; deleteBlob: Scalars['Boolean']['output']; /** Delete a comment */ deleteComment: Scalars['Boolean']['output']; @@ -1925,6 +1944,7 @@ export interface Mutation { revokeMember: Scalars['Boolean']['output']; revokePublicDoc: DocType; revokeUserAccessToken: Scalars['Boolean']['output']; + rotateAuthSigningKey: Array; sendChangeEmail: Scalars['Boolean']['output']; sendChangePasswordEmail: Scalars['Boolean']['output']; sendSetPasswordEmail: Scalars['Boolean']['output']; @@ -2119,6 +2139,10 @@ export interface MutationDeactivateLicenseArgs { workspaceId: Scalars['String']['input']; } +export interface MutationDeleteAuthSigningKeyArgs { + id: Scalars['String']['input']; +} + export interface MutationDeleteBlobArgs { hash?: InputMaybe; key?: InputMaybe; @@ -2312,6 +2336,10 @@ export interface MutationRevokeUserAccessTokenArgs { id: Scalars['String']['input']; } +export interface MutationRotateAuthSigningKeyArgs { + expectedActiveKeyId: Scalars['String']['input']; +} + export interface MutationSendChangeEmailArgs { callbackUrl: Scalars['String']['input']; } @@ -2672,6 +2700,7 @@ export interface Query { adminWorkspacesCount: Scalars['Int']['output']; /** get the whole app configuration */ appConfig: Scalars['JSONObject']['output']; + authSigningKeys: Array; /** Get current user */ currentUser: Maybe; error: ErrorDataUnion; @@ -3455,7 +3484,7 @@ export interface UserType { /** Get user settings */ settings: UserSettingsType; subscriptions: Array; - /** @deprecated use native session exchange instead */ + /** @deprecated use auth session exchange instead */ token: TokenType; } @@ -4191,6 +4220,22 @@ export type AdminWorkspacesCountQuery = { adminWorkspacesCount: number; }; +export type AuthSigningKeysQueryVariables = Exact<{ [key: string]: never }>; + +export type AuthSigningKeysQuery = { + __typename?: 'Query'; + authSigningKeys: Array<{ + __typename?: 'AuthSigningKeyType'; + id: string; + status: string; + source: string; + createdAt: string | null; + retiredAt: string | null; + verifyUntil: string | null; + canDelete: boolean; + }>; +}; + export type CreateChangePasswordUrlMutationVariables = Exact<{ callbackUrl: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -4214,6 +4259,24 @@ export type CreateUserMutation = { createUser: { __typename?: 'UserType'; id: string }; }; +export type DeleteAuthSigningKeyMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type DeleteAuthSigningKeyMutation = { + __typename?: 'Mutation'; + deleteAuthSigningKey: Array<{ + __typename?: 'AuthSigningKeyType'; + id: string; + status: string; + source: string; + createdAt: string | null; + retiredAt: string | null; + verifyUntil: string | null; + canDelete: boolean; + }>; +}; + export type DeleteUserMutationVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -4292,6 +4355,24 @@ export type ListUsersQuery = { }>; }; +export type RotateAuthSigningKeyMutationVariables = Exact<{ + expectedActiveKeyId: Scalars['String']['input']; +}>; + +export type RotateAuthSigningKeyMutation = { + __typename?: 'Mutation'; + rotateAuthSigningKey: Array<{ + __typename?: 'AuthSigningKeyType'; + id: string; + status: string; + source: string; + createdAt: string | null; + retiredAt: string | null; + verifyUntil: string | null; + canDelete: boolean; + }>; +}; + export type SendTestEmailMutationVariables = Exact<{ name: Scalars['String']['input']; host: Scalars['String']['input']; @@ -7866,6 +7947,11 @@ export type Queries = variables: AdminWorkspacesCountQueryVariables; response: AdminWorkspacesCountQuery; } + | { + name: 'authSigningKeysQuery'; + variables: AuthSigningKeysQueryVariables; + response: AuthSigningKeysQuery; + } | { name: 'appConfigQuery'; variables: AppConfigQueryVariables; @@ -8243,6 +8329,11 @@ export type Mutations = variables: CreateUserMutationVariables; response: CreateUserMutation; } + | { + name: 'deleteAuthSigningKeyMutation'; + variables: DeleteAuthSigningKeyMutationVariables; + response: DeleteAuthSigningKeyMutation; + } | { name: 'deleteUserMutation'; variables: DeleteUserMutationVariables; @@ -8263,6 +8354,11 @@ export type Mutations = variables: ImportUsersMutationVariables; response: ImportUsersMutation; } + | { + name: 'rotateAuthSigningKeyMutation'; + variables: RotateAuthSigningKeyMutationVariables; + response: RotateAuthSigningKeyMutation; + } | { name: 'sendTestEmailMutation'; variables: SendTestEmailMutationVariables; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index aee04b2468..1e74796346 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -114,6 +114,26 @@ "session.ttr": { "type": "Number", "desc": "Application auth time to refresh in seconds." + }, + "token.accessTokenTtl": { + "type": "Number", + "desc": "Access JWT expiration time in seconds." + }, + "token.refreshIdleTtl": { + "type": "Number", + "desc": "Auth refresh session inactivity expiration in seconds." + }, + "token.refreshAbsoluteTtl": { + "type": "Number", + "desc": "Auth refresh session absolute expiration in seconds." + }, + "token.refreshGracePeriod": { + "type": "Number", + "desc": "One-use refresh rotation concurrency grace period in seconds." + }, + "token.refreshRetention": { + "type": "Number", + "desc": "Retention for expired auth refresh generations in seconds." } }, "mailer": { diff --git a/packages/frontend/admin/src/modules/settings/config.ts b/packages/frontend/admin/src/modules/settings/config.ts index 03e2f6a5bc..bec4f304e2 100644 --- a/packages/frontend/admin/src/modules/settings/config.ts +++ b/packages/frontend/admin/src/modules/settings/config.ts @@ -3,6 +3,7 @@ import type { ComponentType } from 'react'; import CONFIG_DESCRIPTORS from '../../config.json'; import type { ConfigInputProps } from './config-input-row'; +import { AuthSigningKeys } from './operations/auth-signing-keys'; import { SendTestEmail } from './operations/send-test-email'; export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum'; @@ -75,6 +76,7 @@ export const KNOWN_CONFIG_GROUPS = [ desc: 'Maximum length requirement of password', }, ], + operations: [AuthSigningKeys], } as ConfigGroup<'auth'>, { name: 'Notification', diff --git a/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.spec.tsx b/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.spec.tsx new file mode 100644 index 0000000000..9c3d16e821 --- /dev/null +++ b/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.spec.tsx @@ -0,0 +1,95 @@ +/** + * @vitest-environment happy-dom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + remove: vi.fn(), + rotate: vi.fn(), +})); + +vi.mock('@affine/admin/use-query', () => ({ + useQuery: () => ({ + data: { + authSigningKeys: [ + { + id: 'active-key', + status: 'active', + source: 'auto', + createdAt: '2026-01-01T00:00:00.000Z', + retiredAt: null, + verifyUntil: null, + canDelete: false, + }, + { + id: 'expired-key', + status: 'retiring', + source: 'admin', + createdAt: '2025-01-01T00:00:00.000Z', + retiredAt: '2025-01-02T00:00:00.000Z', + verifyUntil: '2025-01-02T01:00:00.000Z', + canDelete: true, + }, + ], + }, + mutate: mocks.mutate, + }), +})); + +vi.mock('@affine/admin/use-mutation', () => ({ + useMutation: ({ mutation }: { mutation: { id: string } }) => ({ + trigger: + mutation.id === 'rotateAuthSigningKeyMutation' + ? mocks.rotate + : mocks.remove, + isMutating: false, + }), +})); + +vi.mock('../../../components/shared/confirm-dialog', () => ({ + ConfirmDialog: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: () => void; + }) => (open ? : null), +})); + +vi.mock('@affine/component', () => ({ + notify: { error: vi.fn(), success: vi.fn() }, +})); + +import { AuthSigningKeys } from './auth-signing-keys'; + +describe('AuthSigningKeys', () => { + beforeEach(() => { + mocks.mutate.mockReset().mockResolvedValue(undefined); + mocks.remove.mockReset().mockResolvedValue({}); + mocks.rotate.mockReset().mockResolvedValue({}); + }); + + afterEach(cleanup); + + test('rotates the active key and deletes only an expired key', async () => { + render(); + + expect(screen.queryByText(/secret/i)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Rotate key' })); + fireEvent.click(screen.getByRole('button', { name: 'confirm-action' })); + await vi.waitFor(() => + expect(mocks.rotate).toHaveBeenCalledWith({ + expectedActiveKeyId: 'active-key', + }) + ); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(screen.getByRole('button', { name: 'confirm-action' })); + await vi.waitFor(() => + expect(mocks.remove).toHaveBeenCalledWith({ id: 'expired-key' }) + ); + expect(mocks.mutate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.tsx b/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.tsx new file mode 100644 index 0000000000..96baca2ae3 --- /dev/null +++ b/packages/frontend/admin/src/modules/settings/operations/auth-signing-keys.tsx @@ -0,0 +1,184 @@ +import { Badge } from '@affine/admin/components/ui/badge'; +import { Button } from '@affine/admin/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@affine/admin/components/ui/card'; +import { useMutation } from '@affine/admin/use-mutation'; +import { useQuery } from '@affine/admin/use-query'; +import { notify } from '@affine/component'; +import type { UserFriendlyError } from '@affine/error'; +import { + authSigningKeysQuery, + deleteAuthSigningKeyMutation, + rotateAuthSigningKeyMutation, +} from '@affine/graphql'; +import { useMemo, useState } from 'react'; + +import { ConfirmDialog } from '../../../components/shared/confirm-dialog'; + +type PendingAction = + | { type: 'rotate'; keyId: string } + | { type: 'delete'; keyId: string }; + +export function AuthSigningKeys() { + const { data, mutate } = useQuery({ query: authSigningKeysQuery }); + const { trigger: rotate, isMutating: rotating } = useMutation({ + mutation: rotateAuthSigningKeyMutation, + }); + const { trigger: remove, isMutating: deleting } = useMutation({ + mutation: deleteAuthSigningKeyMutation, + }); + const [pending, setPending] = useState(); + const keys = useMemo( + () => + [...data.authSigningKeys].sort((left, right) => + left.status === right.status ? 0 : left.status === 'active' ? -1 : 1 + ), + [data.authSigningKeys] + ); + const active = keys.find(key => key.status === 'active'); + const mutating = rotating || deleting; + + const confirm = async () => { + if (!pending) return; + try { + if (pending.type === 'rotate') { + await rotate({ expectedActiveKeyId: pending.keyId }); + notify.success({ + title: 'Signing key rotated', + message: 'New access tokens now use the replacement key.', + }); + } else { + await remove({ id: pending.keyId }); + notify.success({ + title: 'Signing key deleted', + message: 'The expired signing key was removed.', + }); + } + setPending(undefined); + await mutate(); + } catch (error) { + const friendly = error as UserFriendlyError; + notify.error({ + title: 'Signing key update failed', + message: friendly.message, + }); + } + }; + + return ( + + +
+ Access token signing keys +

+ This server generated and stored its signing key automatically. + Rotate it here when needed; key material is never shown in the admin + panel. +

+
+ +
+ + {keys.length === 0 ? ( +
+ No active signing key is available. Restart the server to retry + automatic initialization. +
+ ) : ( + keys.map(key => { + return ( +
+
+
+ {key.id} + + {key.status === 'active' ? 'Active' : 'Retiring'} + + {key.source === 'auto' ? ( + Auto-generated + ) : null} +
+
+ Created {formatDate(key.createdAt)} + {key.verifyUntil + ? ` · Verifiable until ${formatDate(key.verifyUntil)}` + : ''} + {key.retiredAt + ? ` · Retired ${formatDate(key.retiredAt)}` + : ''} +
+
+ {key.status === 'retiring' ? ( + + ) : null} +
+ ); + }) + )} +
+ + { + if (!open && !mutating) setPending(undefined); + }} + title={ + pending?.type === 'delete' + ? 'Delete signing key?' + : 'Rotate signing key?' + } + description={ + pending?.type === 'delete' + ? 'The expired key will be permanently removed.' + : 'A new key will become active immediately. The current key remains available only long enough to verify access tokens already issued.' + } + confirmText={pending?.type === 'delete' ? 'Delete key' : 'Rotate key'} + confirmButtonVariant={ + pending?.type === 'delete' ? 'destructive' : 'default' + } + onConfirm={() => { + confirm().catch(console.error); + }} + /> +
+ ); +} + +function formatDate(value?: string | null) { + return value ? new Date(value).toLocaleString() : 'Unknown'; +} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index acaa7c69f9..ca82c967b1 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,13 +1,13 @@ { - "ar": 95, - "ca": 92, + "ar": 94, + "ca": 91, "da": 4, "de": 100, "el-GR": 90, "en": 100, - "es-AR": 91, - "es-CL": 92, - "es": 91, + "es-AR": 90, + "es-CL": 91, + "es": 90, "fa": 90, "fr": 94, "hi": 1, @@ -18,11 +18,11 @@ "nb-NO": 45, "pl": 92, "pt-BR": 90, - "ru": 93, - "sv-SE": 91, + "ru": 92, + "sv-SE": 90, "tr": 98, "uk": 90, "ur": 98, "zh-Hans": 100, - "zh-Hant": 93 + "zh-Hant": 92 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 39ecef3dd6..9890724a7e 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -9206,6 +9206,34 @@ export function useAFFiNEI18N(): { * `You must sign in first to access this resource.` */ ["error.AUTHENTICATION_REQUIRED"](): string; + /** + * `The access token has expired.` + */ + ["error.ACCESS_TOKEN_EXPIRED"](): string; + /** + * `The access token is invalid.` + */ + ["error.ACCESS_TOKEN_INVALID"](): string; + /** + * `The auth session has expired.` + */ + ["error.AUTH_SESSION_EXPIRED"](): string; + /** + * `The auth session has been revoked.` + */ + ["error.AUTH_SESSION_REVOKED"](): string; + /** + * `The refresh token is invalid.` + */ + ["error.REFRESH_TOKEN_INVALID"](): string; + /** + * `The refresh token has already been used.` + */ + ["error.REFRESH_TOKEN_REUSED"](): string; + /** + * `Auth session service is temporarily unavailable.` + */ + ["error.AUTH_SESSION_TEMPORARILY_UNAVAILABLE"](): string; /** * `You are not allowed to perform this action.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f38cec5df7..2ef0237a98 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2297,6 +2297,13 @@ "error.INVALID_EMAIL_TOKEN": "An invalid email token provided.", "error.LINK_EXPIRED": "The link has expired.", "error.AUTHENTICATION_REQUIRED": "You must sign in first to access this resource.", + "error.ACCESS_TOKEN_EXPIRED": "The access token has expired.", + "error.ACCESS_TOKEN_INVALID": "The access token is invalid.", + "error.AUTH_SESSION_EXPIRED": "The auth session has expired.", + "error.AUTH_SESSION_REVOKED": "The auth session has been revoked.", + "error.REFRESH_TOKEN_INVALID": "The refresh token is invalid.", + "error.REFRESH_TOKEN_REUSED": "The refresh token has already been used.", + "error.AUTH_SESSION_TEMPORARILY_UNAVAILABLE": "Auth session service is temporarily unavailable.", "error.ACTION_FORBIDDEN": "You are not allowed to perform this action.", "error.ACCESS_DENIED": "You do not have permission to access this resource.", "error.EMAIL_VERIFICATION_REQUIRED": "You must verify your email before accessing this resource.", diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index d453e0430e..84fd47acfe 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -35,8 +35,8 @@ declare interface BUILD_CONFIG_TYPE { imageProxyUrl: string; linkPreviewUrl: string; - CAPTCHA_SITE_KEY: string; SENTRY_DSN: string; + CAPTCHA_SITE_KEY: string; } declare var BUILD_CONFIG: BUILD_CONFIG_TYPE; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index fb832b5888..a02f4cbeb5 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -51,8 +51,8 @@ export function getBuildConfig( requestLicenseUrl: 'https://affine.pro/redirect/license', imageProxyUrl: '/api/worker/image-proxy', linkPreviewUrl: '/api/worker/link-preview', - CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '', + CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', }; }, get beta() { diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index bf2aece029..fc6c1d9389 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1117,6 +1117,11 @@ export const PackageList = [ 'packages/common/realtime', ], }, + { + location: 'packages/common/auth', + name: '@affine/auth', + workspaceDependencies: [], + }, { location: 'packages/common/debug', name: '@affine/debug', @@ -1523,6 +1528,7 @@ export type PackageName = | '@affine/docs' | '@affine/server-native' | '@affine/server' + | '@affine/auth' | '@affine/debug' | '@affine/env' | '@affine/error' diff --git a/tsconfig.json b/tsconfig.json index 187223efaf..400524903e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -125,6 +125,7 @@ { "path": "./blocksuite/playground" }, { "path": "./packages/backend/native" }, { "path": "./packages/backend/server" }, + { "path": "./packages/common/auth" }, { "path": "./packages/common/debug" }, { "path": "./packages/common/env" }, { "path": "./packages/common/error" }, diff --git a/yarn.lock b/yarn.lock index 16569bc54b..5ead01cef6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -262,6 +262,14 @@ __metadata: languageName: unknown linkType: soft +"@affine/auth@workspace:packages/common/auth": + version: 0.0.0-use.local + resolution: "@affine/auth@workspace:packages/common/auth" + dependencies: + vitest: "npm:^4.1.8" + languageName: unknown + linkType: soft + "@affine/changelog@workspace:tools/changelog": version: 0.0.0-use.local resolution: "@affine/changelog@workspace:tools/changelog"