mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
feat(server): support refresh token (#15218)
This commit is contained in:
@@ -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
@@ -238,6 +238,7 @@ dependencies = [
|
||||
"doc_extractor",
|
||||
"file-format",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"homedir",
|
||||
"image",
|
||||
"infer",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Vendored
+27
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@affine/auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.27.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user