feat(server): support refresh token (#15218)

This commit is contained in:
DarkSky
2026-07-12 02:39:42 +08:00
committed by GitHub
parent aea128f0b9
commit 02b25e05d8
80 changed files with 5866 additions and 745 deletions
+29 -2
View File
@@ -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
Generated
+1
View File
@@ -238,6 +238,7 @@ dependencies = [
"doc_extractor",
"file-format",
"hex",
"hmac 0.13.0",
"homedir",
"image",
"infer",
+1
View File
@@ -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 }
+27
View File
@@ -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<Chunk>
@@ -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<boolean>
export declare function verifyAuthSessionAccessToken(token: string, expectedKeyId: string, secret: Buffer, now: number): AuthSessionAccessTokenVerification
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
+278
View File
@@ -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<Sha256>;
#[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<String>,
pub auth_session_id: Option<String>,
}
#[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<T: for<'de> Deserialize<'de>>(segment: &str) -> Option<T> {
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<String> {
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<String> {
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::<Vec<_>>();
if segments.len() != 3 {
return invalid_access_token();
}
let Some(header) = decode_segment::<ParsedAccessTokenHeader>(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::<ParsedAccessTokenClaims>(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<ParsedAuthSessionRefreshToken> {
let segments = token.split('.').collect::<Vec<_>>();
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"
);
}
}
+1
View File
@@ -2,6 +2,7 @@
mod utils;
pub mod auth_session;
pub mod content_policy;
pub mod doc;
pub mod doc_loader;
@@ -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;
+50 -2
View File
@@ -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
@@ -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');
}
);
}
@@ -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<Record<string, unknown>>;
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,
},
});
}
@@ -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);
@@ -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);
@@ -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: {
@@ -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.',
});
}
);
@@ -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;
@@ -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 => {
@@ -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;
@@ -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);
@@ -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<INestApplication>() {
async initTestingDB() {
await initTestingDB(this);
await this.get(AuthSigningKeyRing).onConfigInit();
this.clearAuth();
}
+20
View File
@@ -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<number> {
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<number> {
return this.redis.decrby(key, count).catch(() => 0);
}
+2
View File
@@ -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',
@@ -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.',
@@ -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,
@@ -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<SignedAccessToken> {
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<AuthSessionPrincipal> {
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;
}
@@ -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<AuthRefreshResult> {
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<string>(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;
}
}
@@ -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';
@@ -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),
},
});
@@ -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<PreflightResponse> {
if (!params?.email) {
async preflight(@Body() body?: unknown): Promise<PreflightResponse> {
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<AuthSessionPrincipal>)
.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<AuthSessionPrincipal> | 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<AuthSessionPrincipal> | 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();
}
}
+31 -61
View File
@@ -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<string, semver.Range | null>();
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<Session | null> {
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<Session | null> {
if (req.session && req.session.sessionId === sessionId) return req.session;
const parsedSessionId = SessionIdSchema.safeParse(sessionId);
if (!parsedSessionId.success) return null;
const { userId } = getSessionOptionsFromRequest(req);
const userSession = await this.auth.getUserSession(
parsedSessionId.data,
userId
);
if (!userSession) return null;
req.session = { ...userSession.session, user: userSession.user };
const versionAllowed = await this.checkUserSessionClientVersion(
req,
req.session,
res,
isPublic
);
if (!versionAllowed) {
req.session = undefined;
return null;
}
req.authType = 'session';
return req.session;
}
async signInWithCookie(
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<AuthSessionPrincipal>)
.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;
}
+15 -5
View File
@@ -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';
+45 -27
View File
@@ -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(
@@ -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();
}
}
@@ -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<Session> {
let payload: string | JwtPayload;
try {
payload = jwt.verify(token, this.currentKey, {
algorithms: ['HS256'],
audience: JWT_SESSION_AUDIENCE,
issuer: JWT_SESSION_ISSUER,
});
} catch {
throw new AuthenticationRequired();
}
if (!isUserSessionJwtPayload(payload)) throw new AuthenticationRequired();
const userSession = await this.models.session
.findUserSessionsBySessionId(payload.sid)
.then(sessions => sessions.find(s => s.userId === payload.sub));
if (!userSession) throw new AuthenticationRequired();
const user = await this.models.user.get(payload.sub);
if (!user) throw new AuthenticationRequired();
return { ...userSession, user: sessionUser(user) as CurrentUser };
}
}
export function isLikelyJwt(token: string) {
return token.split('.').length === 3;
}
@@ -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<SessionExchangePayload>(
'native_session_exchange',
{ userId, sessionId },
60 * 1000
);
}
async exchange(req: Request, code: string) {
if (!isNativeClientRequest(req)) {
throw new ActionForbidden();
}
const payload = await this.challenges.consume<SessionExchangePayload>(
'native_session_exchange',
code
);
if (!payload?.userId || !payload.sessionId) {
throw new InvalidAuthState();
}
const session = await this.auth.getUserSession(
payload.sessionId,
payload.userId
);
if (!session) {
throw new InvalidAuthState();
}
return this.jwtSession.sign(payload.userId, payload.sessionId);
}
}
@@ -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;
@@ -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(),
@@ -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<SessionExchangePayload>(
'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<SessionExchangePayload>(
'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,
},
};
}
}
@@ -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<IssuedSession> {
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,
};
}
}
@@ -66,6 +66,11 @@ export type Session = UserSession & {
user: CurrentUser;
};
export type AuthSessionPrincipal = Session & {
authSessionId: string;
authenticatedAt: Date;
};
export type TokenSession = AccessToken & {
user: CurrentUser;
};
@@ -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);
}
}
@@ -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<typeof signingKeySchema>;
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<string, never>;
'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<AuthSigningKeyMetadata[]> {
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;
}
}
@@ -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 => {
@@ -73,7 +73,7 @@ export class ServerService implements OnApplicationBootstrap {
user: string,
updates: Array<{ module: string; key: string; value: any }>
): Promise<DeepPartial<AppConfig>> {
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<AppConfig> = {};
configs.forEach(config => {
@@ -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);
@@ -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<RotateAuthRefreshTokenResult> {
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)
);
}
}
+45 -2
View File
@@ -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,
},
});
}
}
@@ -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';
@@ -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<Array<Pick<User, 'id' | 'disabled'>>>`
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 },
});
+10
View File
@@ -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,
@@ -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(),
},
});
@@ -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'
);
}
}
@@ -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;
@@ -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<typeof validator>;
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<typeof turnstileResponse>
>;
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<string>('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<string>(
'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);
@@ -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: {
/**
@@ -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' });
}
@@ -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();
@@ -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';
+21 -1
View File
@@ -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 {
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@affine/auth",
"private": true,
"type": "module",
"version": "0.27.0",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"vitest": "^4.1.8"
}
}
+1
View File
@@ -0,0 +1 @@
export * from './token-broker';
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<string>>()
.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<AuthTokenPair | null>();
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<AuthTokenPair | null>();
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<AuthTokenResponse>();
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<AuthTokenResponse>();
transport.refresh.mockReturnValueOnce(rotated.promise);
const request = vi
.fn<(token: string) => Promise<string>>()
.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<AuthTokenResponse>();
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<AuthTokenPair | null>>()
.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',
});
});
});
+464
View File
@@ -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<AuthTokenPair | null>;
save(pair: AuthTokenPair): Promise<void>;
clear(): Promise<void>;
}
export interface AuthTokenTransport {
refresh(refreshToken: string): Promise<AuthTokenResponse>;
}
export interface AuthTokenBrokerContract {
getValidAccessToken(minValidity?: number): Promise<string | null>;
refresh(reason: string): Promise<AuthAccessToken>;
clear(reason: string): Promise<void>;
observeAuthState(listener: (state: AuthState) => void): () => void;
}
export interface AuthTokenBrokerOptions {
now?: () => number;
random?: () => number;
retryDelays?: number[];
sleep?: (delay: number) => Promise<void>;
}
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<void> | null = null;
private refreshPromise: Promise<AuthTokenPair> | null = null;
private pending: { pair: AuthTokenPair; epoch: number } | null = null;
private mutationEpoch = 0;
private storageMutation: Promise<void> = 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<void>;
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<void>
) {
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<T>(operation: () => Promise<T>) {
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<T>(
broker: AuthTokenBrokerContract,
request: (accessToken: string) => Promise<T>
) {
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: <T>(request: (accessToken: string) => Promise<T>) =>
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<AuthTokenPair>;
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))
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": []
}
@@ -0,0 +1,11 @@
query authSigningKeys {
authSigningKeys {
id
status
source
createdAt
retiredAt
verifyUntil
canDelete
}
}
@@ -0,0 +1,11 @@
mutation deleteAuthSigningKey($id: String!) {
deleteAuthSigningKey(id: $id) {
id
status
source
createdAt
retiredAt
verifyUntil
canDelete
}
}
@@ -0,0 +1,11 @@
mutation rotateAuthSigningKey($expectedActiveKeyId: String!) {
rotateAuthSigningKey(expectedActiveKeyId: $expectedActiveKeyId) {
id
status
source
createdAt
retiredAt
verifyUntil
canDelete
}
}
+49 -1
View File
@@ -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 = {
+97 -1
View File
@@ -365,6 +365,17 @@ export interface AudioSliceManifestItemType {
startSec: Scalars['Float']['output'];
}
export interface AuthSigningKeyType {
__typename?: 'AuthSigningKeyType';
canDelete: Scalars['Boolean']['output'];
createdAt: Maybe<Scalars['DateTime']['output']>;
id: Scalars['String']['output'];
retiredAt: Maybe<Scalars['DateTime']['output']>;
source: Scalars['String']['output'];
status: Scalars['String']['output'];
verifyUntil: Maybe<Scalars['DateTime']['output']>;
}
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<AuthSigningKeyType>;
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<AuthSigningKeyType>;
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<Scalars['String']['input']>;
key?: InputMaybe<Scalars['String']['input']>;
@@ -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<AuthSigningKeyType>;
/** Get current user */
currentUser: Maybe<UserType>;
error: ErrorDataUnion;
@@ -3455,7 +3484,7 @@ export interface UserType {
/** Get user settings */
settings: UserSettingsType;
subscriptions: Array<SubscriptionType>;
/** @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;
+20
View File
@@ -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": {
@@ -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',
@@ -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 ? <button onClick={onConfirm}>confirm-action</button> : 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(<AuthSigningKeys />);
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);
});
});
@@ -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<PendingAction>();
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 (
<Card className="border-border/60 shadow-none">
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div className="space-y-1">
<CardTitle className="text-sm">Access token signing keys</CardTitle>
<p className="text-xs leading-5 text-muted-foreground">
This server generated and stored its signing key automatically.
Rotate it here when needed; key material is never shown in the admin
panel.
</p>
</div>
<Button
type="button"
size="sm"
disabled={!active || mutating}
onClick={() => {
if (active) setPending({ type: 'rotate', keyId: active.id });
}}
>
Rotate key
</Button>
</CardHeader>
<CardContent className="space-y-3">
{keys.length === 0 ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
No active signing key is available. Restart the server to retry
automatic initialization.
</div>
) : (
keys.map(key => {
return (
<div
key={key.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-3"
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<code className="truncate text-xs">{key.id}</code>
<Badge
variant={
key.status === 'active' ? 'default' : 'secondary'
}
>
{key.status === 'active' ? 'Active' : 'Retiring'}
</Badge>
{key.source === 'auto' ? (
<Badge variant="outline">Auto-generated</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground">
Created {formatDate(key.createdAt)}
{key.verifyUntil
? ` · Verifiable until ${formatDate(key.verifyUntil)}`
: ''}
{key.retiredAt
? ` · Retired ${formatDate(key.retiredAt)}`
: ''}
</div>
</div>
{key.status === 'retiring' ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={!key.canDelete || mutating}
title={
key.canDelete
? 'Delete expired key'
: 'This key can be deleted after its verification window ends.'
}
onClick={() =>
setPending({ type: 'delete', keyId: key.id })
}
>
Delete
</Button>
) : null}
</div>
);
})
)}
</CardContent>
<ConfirmDialog
open={!!pending}
onOpenChange={open => {
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);
}}
/>
</Card>
);
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : 'Unknown';
}
@@ -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
}
+28
View File
@@ -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.`
*/
@@ -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.",
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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() {
+6
View File
@@ -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'
+1
View File
@@ -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" },
+8
View File
@@ -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"