feat(server): passkey pre-refactor (#15060)

#### PR Dependency Tree


* **PR #15060** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* OpenApp native sign-in and native session exchange (JWT) for mobile &
desktop.
  * Centralized short-lived auth challenge store for one-time tokens.
* Encrypted per-endpoint token storage and native token handlers
(Android, iOS, Electron).

* **Improvements**
* Richer auth-method reporting (password, magic link, OAuth, passkey)
and improved sign-in flows.
* Hardened magic-link, OAuth, and session issuance; JWT-backed sessions
and websocket JWT support.
* UX tweaks: form-based password submit, OTP autocomplete, adjusted
captcha flow.

* **Bug Fixes**
  * Expanded tests and auth-state resets to avoid cross-test leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import type { TestFn } from 'ava';
import ava from 'ava';
import supertest from 'supertest';
import {
changeEmail,
@@ -33,6 +34,10 @@ test('change email', async t => {
const u2Email = 'u2@affine.pro';
const user = await app.signupV1(u1Email);
const signedIn = await currentUser(app);
const jwt = signedIn?.token.token;
t.truthy(jwt);
await sendChangeEmail(app, u1Email, '/email-change');
const changeMail = app.mails.last('ChangeEmail');
@@ -77,7 +82,16 @@ test('change email', async t => {
t.is(changedMail.to, u2Email);
t.is(changedMail.props.to, u2Email);
await app.logout();
const revokedCookieSession = await currentUser(app);
t.is(revokedCookieSession, null);
const revokedJwtSession = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${jwt}`)
.expect(200);
t.falsy(revokedJwtSession.body.user);
app.clearAuth();
await app.login({
...user,
email: u2Email,
@@ -0,0 +1,116 @@
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { SessionCache } from '../../base';
import { AuthChallengeStore, AuthModule } from '../../core/auth';
import { createTestingApp, TestingApp } from '../utils';
const test = ava as TestFn<{
app: TestingApp;
challenges: AuthChallengeStore;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AuthModule],
});
t.context.app = app;
t.context.challenges = app.get(AuthChallengeStore);
});
test.beforeEach(() => {
Sinon.restore();
});
test.after.always(async t => {
await t.context.app.close();
});
test('should create and get challenge payload without consuming it', async t => {
const token = await t.context.challenges.create(
'oauth_state',
{ provider: 'Google' },
30_000
);
t.deepEqual(await t.context.challenges.get('oauth_state', token), {
provider: 'Google',
});
t.deepEqual(await t.context.challenges.get('oauth_state', token), {
provider: 'Google',
});
});
test('should consume challenge payload once', async t => {
const token = await t.context.challenges.create(
'open_app_sign_in',
{ userId: 'u1' },
30_000
);
t.deepEqual(await t.context.challenges.consume('open_app_sign_in', token), {
userId: 'u1',
});
t.is(await t.context.challenges.consume('open_app_sign_in', token), null);
});
test('should isolate challenges by purpose', async t => {
const token = await t.context.challenges.create(
'open_app_sign_in',
{ userId: 'u1' },
30_000
);
t.is(await t.context.challenges.get('oauth_state', token), null);
t.is(await t.context.challenges.consume('oauth_state', token), null);
t.deepEqual(await t.context.challenges.consume('open_app_sign_in', token), {
userId: 'u1',
});
});
test('should return null for expired challenge', async t => {
const token = await t.context.challenges.create(
'open_app_sign_in',
{ userId: 'u1' },
1
);
await new Promise(resolve => setTimeout(resolve, 10));
t.is(await t.context.challenges.get('open_app_sign_in', token), null);
t.is(await t.context.challenges.consume('open_app_sign_in', token), null);
});
test('should reject invalid challenge ttl', async t => {
await t.throwsAsync(
t.context.challenges.create('open_app_sign_in', { userId: 'u1' }, 0),
{ message: /Invalid auth state/ }
);
});
test('should reject challenge creation when cache write fails', async t => {
Sinon.stub(t.context.app.get(SessionCache), 'set').resolves(false);
await t.throwsAsync(
t.context.challenges.create('open_app_sign_in', { userId: 'u1' }, 30_000),
{ message: /Invalid auth state/ }
);
});
test('should atomically allow one concurrent consume', async t => {
const token = await t.context.challenges.create(
'open_app_sign_in',
{ userId: 'u1' },
30_000
);
const results = await Promise.all(
Array.from({ length: 8 }, () =>
t.context.challenges.consume('open_app_sign_in', token)
)
);
t.is(results.filter(Boolean).length, 1);
t.deepEqual(results.find(Boolean), { userId: 'u1' });
});
@@ -3,11 +3,17 @@ import { IncomingMessage } from 'node:http';
import { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import ava, { ExecutionContext, TestFn } from 'ava';
import Sinon from 'sinon';
import supertest from 'supertest';
import { parseCookies as safeParseCookies } from '../../base/utils/request';
import { ConfigFactory } from '../../base';
import {
getRequestCookie,
getRequestHeader,
parseCookies as safeParseCookies,
} from '../../base/utils/request';
import { MagicLinkAuthService } from '../../core/auth/magic-link';
import { AuthService } from '../../core/auth/service';
import {
createTestingApp,
@@ -18,7 +24,9 @@ import {
const test = ava as TestFn<{
auth: AuthService;
magicLink: MagicLinkAuthService;
db: PrismaClient;
config: ConfigFactory;
app: TestingApp;
}>;
@@ -26,13 +34,18 @@ test.before(async t => {
const app = await createTestingApp();
t.context.auth = app.get(AuthService);
t.context.magicLink = app.get(MagicLinkAuthService);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
t.context.app = app;
});
test.beforeEach(async t => {
Sinon.reset();
await t.context.app.initTestingDB();
t.context.config.override({
auth: { allowSignup: true, requireEmailDomainVerification: false },
});
});
test.after.always(async t => {
@@ -44,15 +57,102 @@ test('should be able to sign in with credential', async t => {
const u1 = await app.createUser('u1@affine.pro');
await app
const res = await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password })
.expect(200);
t.is(res.body.id, u1.id);
t.falsy(res.body.token);
t.falsy(res.body.expiresAt);
const session = await currentUser(app);
t.is(session?.id, u1.id);
});
async function exchangeSession(app: TestingApp, code: string) {
return await supertest(app.getHttpServer())
.post('/api/auth/native/exchange')
.set('x-affine-client-kind', 'native')
.send({ code })
.expect(201);
}
function assertClearsNativeAuthCookies(
t: ExecutionContext,
res: supertest.Response
) {
const setCookies = res.get('Set-Cookie') ?? [];
for (const name of [
AuthService.sessionCookieName,
AuthService.userCookieName,
AuthService.csrfCookieName,
]) {
t.true(
setCookies.some(
cookie =>
cookie.startsWith(`${name}=;`) &&
/Expires=Thu, 01 Jan 1970/i.test(cookie)
)
);
}
}
test('should issue exchange code only for native credential sign in', async t => {
const { app } = t.context;
const u1 = await app.createUser('native@affine.pro');
const res = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: u1.email, password: u1.password })
.expect(200);
t.is(res.body.id, u1.id);
t.truthy(res.body.exchangeCode);
assertClearsNativeAuthCookies(t, res);
const exchangeRes = await exchangeSession(app, res.body.exchangeCode);
t.truthy(exchangeRes.body.token);
t.truthy(exchangeRes.body.expiresAt);
});
test('should not issue jwt for browser-origin credential sign in', async t => {
const { app } = t.context;
const u1 = await app.createUser('browser@affine.pro');
const res = await app
.POST('/api/auth/sign-in')
.set('origin', 'https://app.affine.pro')
.set('x-affine-client-kind', 'native')
.send({ email: u1.email, password: u1.password })
.expect(200);
t.is(res.body.id, u1.id);
t.falsy(res.body.token);
t.falsy(res.body.expiresAt);
t.falsy(res.body.exchangeCode);
});
test('should write legacy auth cookies when signing in with credential', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const res = await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password })
.expect(200);
const cookies = parseCookies(res);
t.truthy(cookies[AuthService.sessionCookieName]);
t.truthy(cookies[AuthService.userCookieName]);
t.truthy(cookies[AuthService.csrfCookieName]);
});
test('should record sign in client version when header is provided', async t => {
const { app, db } = t.context;
@@ -81,6 +181,126 @@ test('should record sign in client version when header is provided', async t =>
t.is(userSession2?.signInClientVersion, '0.25.1');
});
test('should return method-oriented preflight for registered password users', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const res = await app
.POST('/api/auth/preflight')
.send({ email: u1.email })
.expect(201);
t.true(res.body.registered);
t.deepEqual(res.body.methods.password, { available: true });
t.deepEqual(res.body.methods.magicLink, { available: true });
t.deepEqual(res.body.methods.passkey, {
available: false,
discoverable: false,
});
t.false('hasPassword' in res.body);
});
test('should return method-oriented preflight for unknown users', async t => {
const { app } = t.context;
const res = await app
.POST('/api/auth/preflight')
.send({ email: 'unknown@affine.pro' })
.expect(201);
t.false(res.body.registered);
t.deepEqual(res.body.methods.password, { available: false });
t.deepEqual(res.body.methods.magicLink, { available: true });
t.deepEqual(res.body.methods.passkey, {
available: false,
discoverable: false,
});
t.false('hasPassword' in res.body);
});
test('should return password unavailable for registered users without password', async t => {
const { app } = t.context;
const u1 = await app.createUser('passwordless@affine.pro', {
password: null,
});
const res = await app
.POST('/api/auth/preflight')
.send({ email: u1.email })
.expect(201);
t.true(res.body.registered);
t.deepEqual(res.body.methods.password, { available: false });
t.false('hasPassword' in res.body);
});
test('should return methods unavailable for disabled users', async t => {
const { app } = t.context;
const u1 = await app.createUser('disabled@affine.pro', {
disabled: true,
});
const res = await app
.POST('/api/auth/preflight')
.send({ email: u1.email })
.expect(201);
t.false(res.body.registered);
t.deepEqual(res.body.methods.password, { available: false });
t.deepEqual(res.body.methods.magicLink, { available: false });
});
test('should return magic link unavailable for unknown users when signup is disabled', async t => {
const { app, config } = t.context;
config.override({
auth: {
allowSignup: false,
},
});
const res = await app
.POST('/api/auth/preflight')
.send({ email: 'unknown@affine.pro' })
.expect(201);
t.false(res.body.registered);
t.deepEqual(res.body.methods.magicLink, { available: false });
});
test('should return magic link unavailable when domain verification rejects signup email', async t => {
const { app, config } = t.context;
config.override({
auth: {
requireEmailDomainVerification: true,
},
});
const res = await app
.POST('/api/auth/preflight')
.send({ email: 'unknown+alias@affine.pro' })
.expect(201);
t.false(res.body.registered);
t.deepEqual(res.body.methods.magicLink, { available: false });
});
test('should return bound auth methods for current account', async t => {
const { app } = t.context;
await app.signupV1('bound-methods@affine.pro');
const res = await app.GET('/api/auth/methods').expect(200);
t.deepEqual(res.body.password, { bound: true });
t.deepEqual(res.body.oauth, { bound: false, providers: [] });
t.deepEqual(res.body.passkey, { bound: false, count: 0 });
});
test('should be able to sign in with email', async t => {
const { app } = t.context;
@@ -100,7 +320,19 @@ test('should be able to sign in with email', async t => {
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
await app.POST('/api/auth/magic-link').send({ email, token }).expect(201);
const signInRes = await app
.POST('/api/auth/magic-link')
.send({ email, token })
.expect(201);
t.is(signInRes.body.id, u1.id);
t.falsy(signInRes.body.token);
t.falsy(signInRes.body.expiresAt);
const cookies = parseCookies(signInRes);
t.truthy(cookies[AuthService.sessionCookieName]);
t.truthy(cookies[AuthService.userCookieName]);
t.truthy(cookies[AuthService.csrfCookieName]);
const session = await currentUser(app);
t.is(session?.id, u1.id);
@@ -140,6 +372,17 @@ test('should not be able to sign in if email is invalid', async t => {
t.is(res.body.message, 'An invalid email provided: ');
});
test('should not create magic-link state if email is invalid', async t => {
const { app, magicLink } = t.context;
await t.throwsAsync(magicLink.send('invalid-email'), {
message: 'An invalid email provided: invalid-email',
});
t.is(app.mails.count('SignIn'), 0);
t.is(app.mails.count('SignUp'), 0);
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth } = t.context;
@@ -202,7 +445,7 @@ test('should be able to sign out', async t => {
t.falsy(session);
});
test('should be able to sign out when csrf header is missing (compat)', async t => {
test('should reject cookie sign out when csrf header is missing', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
@@ -220,16 +463,134 @@ test('should be able to sign out when csrf header is missing (compat)', async t
await supertest(app.getHttpServer())
.post('/api/auth/sign-out')
.set('Cookie', cookieHeader)
.expect(200);
.expect(HttpStatus.FORBIDDEN);
const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Cookie', cookieHeader)
.expect(200);
t.is(sessionRes.body.user.id, u1.id);
});
test('should be able to sign out with jwt without csrf', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const signInRes = await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: u1.email, password: u1.password })
.expect(200);
const token = (await exchangeSession(app, signInRes.body.exchangeCode)).body
.token;
await supertest(app.getHttpServer())
.post('/api/auth/sign-out')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${token}`)
.expect(200);
t.falsy(sessionRes.body.user);
});
test('should ignore user_id query when signing out with jwt', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const u2 = await app.createUser('u2@affine.pro');
const u1SignIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: u1.email, password: u1.password })
.expect(200);
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
.token;
await app
.POST('/api/auth/sign-in')
.send({ email: u2.email, password: u2.password })
.expect(200);
await supertest(app.getHttpServer())
.post(`/api/auth/sign-out?user_id=${u2.id}`)
.set('Authorization', `Bearer ${u1Token}`)
.expect(200);
const u1Session = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${u1Token}`)
.expect(200);
t.falsy(u1Session.body.user);
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 => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const u2 = await app.createUser('u2@affine.pro');
const u1SignIn = await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: u1.email, password: u1.password })
.expect(200);
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
.token;
const u2SignIn = await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.set('Authorization', `Bearer ${u1Token}`)
.send({ email: u2.email, password: u2.password })
.expect(200);
const u1Session = await t.context.db.userSession.findFirstOrThrow({
where: { userId: u1.id },
});
const u2Session = await t.context.db.userSession.findFirstOrThrow({
where: { userId: u2.id },
});
t.is(u2SignIn.body.id, u2.id);
t.is(u2Session.sessionId, u1Session.sessionId);
});
test('should not reuse legacy bearer session id when signing in another account without cookies', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const u2 = await app.createUser('u2@affine.pro');
await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password })
.expect(200);
const u1Session = await t.context.db.userSession.findFirstOrThrow({
where: { userId: u1.id },
});
await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.set('Authorization', `Bearer ${u1Session.sessionId}`)
.send({ email: u2.email, password: u2.password })
.expect(200);
const u2Session = await t.context.db.userSession.findFirstOrThrow({
where: { userId: u2.id },
});
t.not(u2Session.sessionId, u1Session.sessionId);
});
test('should be able to sign out when duplicated csrf cookies exist', async t => {
const { app } = t.context;
@@ -264,23 +625,6 @@ test('should be able to sign out when duplicated csrf cookies exist', async t =>
t.falsy(sessionRes.body.user);
});
test('should be able to sign out via GET /api/auth/sign-out (deprecated)', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password })
.expect(200);
const res = await app.GET('/api/auth/sign-out').expect(200);
t.is(res.headers.deprecation, 'true');
const session = await currentUser(app);
t.falsy(session);
});
test('should reject sign out when csrf token mismatched', async t => {
const { app } = t.context;
@@ -317,20 +661,20 @@ test('should sign in desktop app via one-time open-app code', async t => {
const exchangeRes = await supertest(app.getHttpServer())
.post('/api/auth/open-app/sign-in')
.set('x-affine-client-kind', 'native')
.send({ code })
.expect(201);
const exchangedCookies = exchangeRes.get('Set-Cookie') ?? [];
t.true(
exchangedCookies.some(c =>
c.startsWith(`${AuthService.sessionCookieName}=`)
)
);
t.is(exchangeRes.body.id, u1.id);
t.truthy(exchangeRes.body.exchangeCode);
assertClearsNativeAuthCookies(t, exchangeRes);
const tokenRes = await exchangeSession(app, exchangeRes.body.exchangeCode);
t.truthy(tokenRes.body.token);
t.truthy(tokenRes.body.expiresAt);
const cookieHeader = exchangedCookies.map(c => c.split(';')[0]).join('; ');
const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Cookie', cookieHeader)
.set('Authorization', `Bearer ${tokenRes.body.token}`)
.expect(200);
t.is(sessionRes.body.user?.id, u1.id);
@@ -379,6 +723,35 @@ test('should not throw on parse of a bad cookie', async t => {
t.is(req.cookies?.[badCookieKey], badCookieVal);
});
test('should only read string request cookies', t => {
const req = {
headers: {},
cookies: {
empty: '',
list: ['session'],
object: { value: 'session' },
session: 'valid_session',
},
} as unknown as IncomingMessage & { cookies?: Record<string, unknown> };
t.is(getRequestCookie(req, 'session'), 'valid_session');
t.is(getRequestCookie(req, 'empty'), undefined);
t.is(getRequestCookie(req, 'list'), undefined);
t.is(getRequestCookie(req, 'object'), undefined);
});
test('should only read string request headers', t => {
const req = {
headers: {
'x-list': ['value'],
'x-string': 'value',
},
} as unknown as IncomingMessage;
t.is(getRequestHeader(req, 'x-string'), 'value');
t.is(getRequestHeader(req, 'x-list'), undefined);
});
// multiple accounts session tests
test('should be able to sign in another account in one session', async t => {
const { app } = t.context;
@@ -400,15 +773,6 @@ test('should be able to sign in another account in one session', async t => {
.send({ email: u2.email, password: u2.password })
.expect(200);
// list [u1, u2]
const sessions = await app.GET('/api/auth/sessions').expect(200);
t.is(sessions.body.users.length, 2);
t.like(
sessions.body.users.map((u: any) => u.id),
[u1.id, u2.id]
);
// default to latest signed in user: u2
let session = await app.GET('/api/auth/session').expect(200);
@@ -0,0 +1,56 @@
import ava from 'ava';
import { verifyEmailDomainRecords } from '../../core/auth/email-domain';
const test = ava;
test('should verify email domain records', async t => {
const ok = await verifyEmailDomainRecords(
'user@example.com',
{
resolveMx: async () => [{ exchange: 'mx.example.com', priority: 10 }],
resolveTxt: async domain =>
domain === '_dmarc.example.com'
? [['v=DMARC1; p=none']]
: [['v=spf1 include:_spf.example.com ~all']],
},
100
);
t.true(ok);
});
test('should verify split txt record chunks', async t => {
const ok = await verifyEmailDomainRecords(
'user@example.com',
{
resolveMx: async () => [{ exchange: 'mx.example.com', priority: 10 }],
resolveTxt: async domain =>
domain === '_dmarc.example.com'
? [['v=DM', 'ARC1; p=none']]
: [['v=spf', '1 include:_spf.example.com ~all']],
},
100
);
t.true(ok);
});
test('should fail closed when email domain lookup times out', async t => {
const ok = await verifyEmailDomainRecords(
'user@example.com',
{
resolveMx: async () =>
new Promise(resolve =>
setTimeout(
() => resolve([{ exchange: 'mx.example.com', priority: 10 }]),
50
)
),
resolveTxt: async () => [['v=spf1 include:_spf.example.com ~all']],
},
1
);
t.false(ok);
});
@@ -5,7 +5,13 @@ import Sinon from 'sinon';
import request from 'supertest';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base';
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
import {
AuthModule,
CurrentUser,
JwtSessionService,
Public,
Session,
} from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils';
@@ -37,6 +43,7 @@ const test = ava as TestFn<{
app: TestingApp;
server: any;
auth: AuthService;
jwtSession: JwtSessionService;
models: Models;
db: PrismaClient;
config: ConfigFactory;
@@ -53,6 +60,7 @@ 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.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
@@ -110,7 +118,7 @@ test('should not be able to visit private api if not signed in', async t => {
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
test('should be able to visit private api with cookie session', async t => {
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
@@ -119,16 +127,115 @@ test('should be able to visit private api if signed in', async t => {
t.is(res.body.user.id, t.context.u1.id);
});
test('should be able to visit private api with access token', async t => {
const models = t.context.app.get(Models);
const token = await models.accessToken.create({
test('should be able to visit private api with legacy bearer session id', async t => {
const res = 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);
});
test('should be able to visit private api with personal access token', async t => {
const accessToken = await t.context.models.accessToken.create({
userId: t.context.u1.id,
name: 'test',
});
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${token.token}`)
.set('Authorization', `Bearer ${accessToken.token}`)
.expect(HttpStatus.OK);
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);
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${jwt.token}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, t.context.u1.id);
});
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 res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('Authorization', `Bearer ${jwt.token}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, u2.id);
});
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);
await t.context.auth.signOut(t.context.sessionId, t.context.u1.id);
await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${jwt.token}`)
.expect(HttpStatus.UNAUTHORIZED);
t.pass();
});
test('should enforce client version for jwt and bearer session id auth', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
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;
},
},
];
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
);
}
});
test('should fall back to cookie session on public api when jwt is invalid', 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);
t.is(res.body.user.id, t.context.u1.id);
@@ -0,0 +1,182 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import jwt from 'jsonwebtoken';
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;
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.models = app.get(Models);
t.context.db = app.get(PrismaClient);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
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();
});
function currentJwtKey(crypto: CryptoHelper) {
return Buffer.concat([
Buffer.from('affine:user-session-jwt:v1:'),
crypto.keyPair.sha256.privateKey,
]);
}
test('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('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('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.',
});
});
@@ -0,0 +1,64 @@
import ava, { TestFn } from 'ava';
import { AuthMethodsService, AuthModule } from '../../core/auth';
import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils';
const test = ava as TestFn<{
app: TestingApp;
authMethods: AuthMethodsService;
models: Models;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AuthModule],
});
t.context.app = app;
t.context.authMethods = app.get(AuthMethodsService);
t.context.models = app.get(Models);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close();
});
test('should return login preflight methods without top-level has fields', async t => {
const user = await t.context.app.createUser('methods@affine.pro');
const preflight = await t.context.authMethods.loginPreflight(user.email);
t.true(preflight.registered);
t.deepEqual(preflight.methods.password, { available: true });
t.deepEqual(preflight.methods.magicLink, { available: true });
t.deepEqual(preflight.methods.passkey, {
available: false,
discoverable: false,
});
t.false('hasPassword' in preflight);
});
test('should return bound account methods for settings', async t => {
const user = await t.context.app.createUser('bound-methods@affine.pro');
await t.context.models.user.createConnectedAccount({
userId: user.id,
provider: 'Google',
providerAccountId: 'google-account',
accessToken: 'access-token',
});
const methods = await t.context.authMethods.boundMethods(user.id);
t.deepEqual(methods.password, { bound: true });
t.deepEqual(methods.oauth, {
bound: true,
providers: ['Google'],
});
t.deepEqual(methods.passkey, { bound: false, count: 0 });
});
@@ -50,6 +50,13 @@ test('should be able to set cache with ttl', async t => {
t.true(ttl > 0);
});
test('should reject invalid ttl options', async t => {
t.false(await cache.set(key('test-invalid-ttl'), 1, { ttl: 0 }));
t.is(await cache.get(key('test-invalid-ttl')), undefined);
t.false(await cache.setnx(key('test-invalid-ttl-nx'), 1, { ttl: 0 }));
t.is(await cache.get(key('test-invalid-ttl-nx')), undefined);
});
test('should be able to incr/decr number cache', async t => {
t.true(await cache.set(key('test-incr'), 1));
t.is(await cache.increase(key('test-incr')), 2);
@@ -63,6 +63,14 @@ export class TestingApp extends NestApplication {
await this.close();
}
clearAuth() {
this.resetRateLimit();
this.sessionCookie = null;
this.currentUserCookie = null;
this.csrfCookie = null;
this.userCookies.clear();
}
request(
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string
@@ -7,7 +7,7 @@ import { app, e2e, Mockers } from '../test';
e2e('user(email) should return null without auth', async t => {
const user = await app.create(Mockers.User);
await app.logout();
app.clearAuth();
const res = await app.gql({
query: getUserQuery,
@@ -18,7 +18,7 @@ e2e('user(email) should return null without auth', async t => {
});
e2e('user(email) should return null outside workspace scope', async t => {
await app.logout();
app.clearAuth();
const me = await app.signup();
const other = await app.create(Mockers.User);
@@ -43,7 +43,7 @@ e2e('user(email) should return null outside workspace scope', async t => {
});
e2e('user(email) should return user within workspace scope', async t => {
await app.logout();
app.clearAuth();
const me = await app.signup();
const other = await app.create(Mockers.User);
const ws = await app.create(Mockers.Workspace, { owner: me });
@@ -67,7 +67,7 @@ e2e('user(email) should return user within workspace scope', async t => {
});
e2e('user(email) should be rate limited', async t => {
await app.logout();
app.clearAuth();
const me = await app.signup();
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
@@ -7,6 +7,7 @@ import Sinon from 'sinon';
import { AppModule } from '../../app.module';
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
import { SessionCache } from '../../base/cache';
import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
@@ -23,6 +24,7 @@ import { createTestingApp, currentUser, TestingApp } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
oauth: OAuthService;
cache: SessionCache;
models: Models;
u1: CurrentUser;
db: PrismaClient;
@@ -62,6 +64,7 @@ test.before(async t => {
t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService);
t.context.cache = app.get(SessionCache);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.app = app;
@@ -244,6 +247,7 @@ test('should forbid preflight with untrusted redirect_uri', async t => {
test('should throw if client_nonce is missing in preflight', async t => {
const { app } = t.context;
app.clearAuth();
await app
.POST('/api/oauth/preflight')
@@ -293,6 +297,19 @@ test('should be able to save oauth state', async t => {
t.is(state!.provider, OAuthProviderName.Google);
});
test('should save oauth state with three hour ttl', async t => {
const { cache, oauth } = t.context;
const id = await oauth.saveOAuthState({
provider: OAuthProviderName.Google,
});
const ttl = await cache.ttl(`auth_challenge:oauth_state:${id}`);
t.true(ttl > 2 * 3600);
t.true(ttl <= 3 * 3600);
});
test('should be able to get registered oauth providers', async t => {
const { oauth } = t.context;
@@ -550,12 +567,40 @@ test('should be able to sign up with oauth', async t => {
const clientNonce = mockOAuthProvider(app, 'u2@affine.pro');
await app
const res = await app
.POST('/api/oauth/callback')
.set('x-affine-client-kind', 'native')
.send({ code: '1', state: '1', client_nonce: clientNonce })
.expect(HttpStatus.OK);
const sessionUser = await currentUser(app);
t.truthy(res.body.exchangeCode);
const tokenRes = await app
.POST('/api/auth/native/exchange')
.set('x-affine-client-kind', 'native')
.send({ code: res.body.exchangeCode })
.expect(201);
t.truthy(tokenRes.body.token);
t.truthy(tokenRes.body.expiresAt);
const setCookies = res.get('Set-Cookie') ?? [];
for (const name of [
AuthService.sessionCookieName,
AuthService.userCookieName,
AuthService.csrfCookieName,
]) {
t.true(
setCookies.some(
cookie =>
cookie.startsWith(`${name}=;`) &&
/Expires=Thu, 01 Jan 1970/i.test(cookie)
)
);
}
const sessionUserRes = await app
.GET('/api/auth/session')
.set('Authorization', `Bearer ${tokenRes.body.token}`)
.expect(200);
const sessionUser = sessionUserRes.body.user;
t.truthy(sessionUser);
t.is(sessionUser!.email, 'u2@affine.pro');
@@ -60,14 +60,17 @@ async function withTimeout<T>(
}
}
function createClient(url: string, cookie: string): SocketIOClient {
function createClient(
url: string,
cookie?: string,
auth?: Record<string, unknown>
): SocketIOClient {
return io(url, {
transports: ['websocket'],
reconnection: false,
forceNew: true,
extraHeaders: {
cookie,
},
...(cookie ? { extraHeaders: { cookie } } : {}),
...(auth ? { auth } : {}),
});
}
@@ -146,14 +149,24 @@ function expectNoEvent(
async function login(app: TestingApp) {
const user = await app.createUser();
const res = await app
const cookieRes = await app
.POST('/api/auth/sign-in')
.send({ email: user.email, password: user.password })
.expect(200);
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')
.set('x-affine-client-kind', 'native')
.send({ code: nativeRes.body.exchangeCode })
.expect(201);
const cookies = res.get('Set-Cookie') ?? [];
const cookies = cookieRes.get('Set-Cookie') ?? [];
const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
return { user, cookieHeader };
return { user, cookieHeader, token: tokenRes.body.token as string };
}
function createYjsUpdateBase64() {
@@ -217,6 +230,52 @@ test.after.always(async () => {
await app.close();
});
test('should reject websocket legacy session token auth', async t => {
const { cookieHeader } = await login(app);
const sessionCookie = cookieHeader
.split('; ')
.find(cookie => cookie.startsWith('affine_session='));
const token = sessionCookie?.split('=')[1];
t.truthy(token);
const socket = createClient(url, undefined, { token });
try {
await t.throwsAsync(() => waitForConnect(socket));
} finally {
socket.disconnect();
}
});
test('should connect websocket with jwt auth', async t => {
const { token } = await login(app);
const socket = createClient(url, undefined, { token, tokenType: 'jwt' });
try {
await waitForConnect(socket);
t.true(socket.connected);
} finally {
socket.disconnect();
}
});
test('should reject websocket jwt auth after session deletion', async t => {
const { token } = await login(app);
await app
.POST('/api/auth/sign-out')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const socket = createClient(url, undefined, { token, tokenType: 'jwt' });
try {
await t.throwsAsync(() => waitForConnect(socket));
} finally {
socket.disconnect();
}
});
test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', async t => {
const { user, cookieHeader } = await login(app);
const spaceId = user.id;
@@ -110,6 +110,10 @@ export class TestingApp extends ApplyType<INestApplication>() {
async initTestingDB() {
await initTestingDB(this);
this.clearAuth();
}
clearAuth() {
this.sessionCookie = null;
this.currentUserCookie = null;
this.csrfCookie = null;
+23 -2
View File
@@ -7,6 +7,14 @@ export interface CacheSetOptions {
ttl?: number;
}
const GET_AND_DELETE_LUA = `
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
`;
export function isValidCacheTtl(ttl: unknown): ttl is number {
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
}
@@ -32,12 +40,15 @@ export class CacheProvider {
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
if (isValidCacheTtl(opts.ttl)) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl)
.then(() => true)
.catch(() => false);
}
if (opts.ttl !== undefined) {
return false;
}
return this.redis
.set(key, JSON.stringify(value))
@@ -58,12 +69,15 @@ export class CacheProvider {
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
if (isValidCacheTtl(opts.ttl)) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
.then(v => !!v)
.catch(() => false);
}
if (opts.ttl !== undefined) {
return false;
}
return this.redis
.set(key, JSON.stringify(value), 'NX')
@@ -78,6 +92,13 @@ export class CacheProvider {
.catch(() => false);
}
async getAndDelete<T = unknown>(key: string): Promise<T | undefined> {
return this.redis
.eval(GET_AND_DELETE_LUA, 1, key)
.then(v => (typeof v === 'string' ? JSON.parse(v) : undefined))
.catch(() => undefined);
}
async has(key: string): Promise<boolean> {
return this.redis
.exists(key)
@@ -7,12 +7,16 @@ import { GqlArgumentsHost } from '@nestjs/graphql';
import type { Request, Response } from 'express';
import { ClsServiceManager } from 'nestjs-cls';
import type { Socket } from 'socket.io';
import { z } from 'zod';
type RequestResponse = {
req: Request;
res?: Response;
};
const RequestCookieValueSchema = z.string().min(1);
const RequestHeaderValueSchema = z.string().min(1);
export function getRequestResponseFromHost(
host: ArgumentsHost
): RequestResponse {
@@ -68,9 +72,7 @@ export function getRequestResponseFromContext(
export function parseCookies(
req: IncomingMessage & { cookies?: Record<string, string> }
) {
if (req.cookies) {
return;
}
if (req.cookies) return;
const cookieStr = req.headers.cookie ?? '';
req.cookies = cookieStr.split(';').reduce(
@@ -103,6 +105,25 @@ export function parseCookies(
);
}
export function getRequestCookie(
req: IncomingMessage & { cookies?: Record<string, unknown> },
name: string
) {
parseCookies(req as IncomingMessage & { cookies?: Record<string, string> });
const value = req.cookies?.[name];
const parsed = RequestCookieValueSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
export function getRequestHeader(req: IncomingMessage, name: string) {
const value = req.headers[name.toLowerCase()];
const parsed = RequestHeaderValueSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
/**
* Request type
*
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { InvalidAuthState, SessionCache } from '../../base';
import { isValidCacheTtl } from '../../base/cache/provider';
export type AuthChallengePurpose =
| 'oauth_state'
| 'open_app_sign_in'
| 'native_session_exchange'
| 'captcha'
| 'passkey_registration'
| 'passkey_authentication';
@Injectable()
export class AuthChallengeStore {
constructor(private readonly cache: SessionCache) {}
async create<T>(
purpose: AuthChallengePurpose,
payload: T | ((token: string) => T),
ttlMs: number
): Promise<string> {
if (!isValidCacheTtl(ttlMs)) {
throw new InvalidAuthState();
}
const token = randomUUID();
const value =
typeof payload === 'function'
? (payload as (token: string) => T)(token)
: payload;
const stored = await this.cache.set(this.key(purpose, token), value, {
ttl: ttlMs,
});
if (!stored) {
throw new InvalidAuthState();
}
return token;
}
async get<T>(purpose: AuthChallengePurpose, token: string) {
return (await this.cache.get<T>(this.key(purpose, token))) ?? null;
}
async consume<T>(purpose: AuthChallengePurpose, token: string) {
return (await this.cache.getAndDelete<T>(this.key(purpose, token))) ?? null;
}
private key(purpose: AuthChallengePurpose, token: string) {
return `auth_challenge:${purpose}:${token}`;
}
}
@@ -1,4 +1,4 @@
import { resolveMx, resolveTxt, setServers } from 'node:dns/promises';
import { setServers } from 'node:dns/promises';
import {
Body,
@@ -6,7 +6,6 @@ import {
Get,
Header,
HttpStatus,
Logger,
Post,
Query,
Req,
@@ -16,27 +15,33 @@ import type { Request, Response } from 'express';
import {
ActionForbidden,
Config,
CryptoHelper,
EmailTokenNotFound,
getRequestCookie,
InvalidAuthState,
InvalidEmail,
InvalidEmailToken,
SignUpForbidden,
Throttle,
URLHelper,
UseNamedGuard,
WrongSignInCredentials,
} from '../../base';
import { Models, TokenType } from '../../models';
import { Models } from '../../models';
import { validators } from '../utils/validators';
import { Public } from './guard';
import { AuthService } from './service';
import { MagicLinkAuthService } from './magic-link';
import { AuthMethodsService } from './methods';
import { SessionExchangeService } from './native-exchange';
import { OpenAppAuthService } from './open-app';
import { AuthService, sessionUser } from './service';
import { CurrentUser, Session } from './session';
import { SessionIssuer } from './session-issuer';
interface PreflightResponse {
registered: boolean;
hasPassword: boolean;
methods: {
password: { available: boolean };
magicLink: { available: boolean };
oauth: { available: boolean; providers: string[] };
passkey: { available: boolean; discoverable: boolean };
};
}
interface SignInCredential {
@@ -56,17 +61,25 @@ interface OpenAppSignInCredential {
code: string;
}
interface NativeSessionExchangeCredential {
code: string;
}
type SignInResponse = CurrentUser & {
exchangeCode?: string;
};
@Throttle('strict')
@Controller('/api/auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config,
private readonly crypto: CryptoHelper
private readonly sessionIssuer: SessionIssuer,
private readonly magicLink: MagicLinkAuthService,
private readonly openApp: OpenAppAuthService,
private readonly authMethods: AuthMethodsService,
private readonly sessionExchange: SessionExchangeService,
private readonly models: Models
) {
if (env.dev) {
// set DNS servers in dev mode
@@ -89,19 +102,13 @@ export class AuthController {
}
validators.assertValidEmail(params.email);
const user = await this.models.user.getUserByEmail(params.email);
return this.authMethods.loginPreflight(params.email);
}
if (!user) {
return {
registered: false,
hasPassword: false,
};
}
return {
registered: user.registered,
hasPassword: !!user.password,
};
@UseNamedGuard('version')
@Get('/methods')
async boundMethods(@CurrentUser() user: CurrentUser) {
return this.authMethods.boundMethods(user.id);
}
@Public()
@@ -142,10 +149,17 @@ export class AuthController {
email: string,
password: string
) {
const user = await this.auth.signIn(email, password);
const identity = await this.auth.verifyPassword(email, password);
await this.auth.setCookies(req, res, user.id);
res.status(HttpStatus.OK).send(user);
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
const user = await this.models.user.get(identity.userId);
if (!user) {
throw new WrongSignInCredentials({ email });
}
res.status(HttpStatus.OK).send({
...sessionUser(user),
exchangeCode,
} satisfies SignInResponse);
}
async sendMagicLink(
@@ -154,105 +168,10 @@ export class AuthController {
callbackUrl = '/magic-link',
clientNonce?: string
) {
if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
throw new ActionForbidden();
}
const callbackUrlObj = this.url.url(callbackUrl);
const redirectUriInCallback =
callbackUrlObj.searchParams.get('redirect_uri');
if (
redirectUriInCallback &&
!this.url.isAllowedRedirectUri(redirectUriInCallback)
) {
throw new ActionForbidden();
}
// send email magic link
const user = await this.models.user.getUserByEmail(email, {
withDisabled: true,
});
if (!user) {
if (!this.config.auth.allowSignup) {
throw new SignUpForbidden();
}
if (this.config.auth.requireEmailDomainVerification) {
// verify domain has MX, SPF, DMARC records
const [name, domain, ...rest] = email.split('@');
if (rest.length || !domain) {
throw new InvalidEmail({ email });
}
const [mx, spf, dmarc] = await Promise.allSettled([
resolveMx(domain).then(t => t.map(mx => mx.exchange).filter(Boolean)),
resolveTxt(domain).then(t =>
t.map(([k]) => k).filter(txt => txt.includes('v=spf1'))
),
resolveTxt('_dmarc.' + domain).then(t =>
t.map(([k]) => k).filter(txt => txt.includes('v=DMARC1'))
),
]).then(t => t.filter(t => t.status === 'fulfilled').map(t => t.value));
if (!mx?.length || !spf?.length || !dmarc?.length) {
throw new InvalidEmail({ email });
}
// filter out alias emails
if (name.includes('+')) {
throw new InvalidEmail({ email });
}
}
} else if (user.disabled) {
throw new WrongSignInCredentials({ email });
}
const ttlInSec = 30 * 60;
const token = await this.models.verificationToken.create(
TokenType.SignIn,
email,
ttlInSec
);
const otp = this.crypto.otp();
await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce);
const magicLink = this.url.link(callbackUrl, { token: otp, email });
if (env.dev) {
// make it easier to test in dev mode
this.logger.debug(`Magic link: ${magicLink}`);
}
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
res.status(HttpStatus.OK).send({
email: email,
});
const payload = await this.magicLink.send(email, callbackUrl, clientNonce);
res.status(HttpStatus.OK).send(payload);
}
@Public()
/**
* @deprecated Kept for 0.25 clients that still call GET `/api/auth/sign-out`.
* Use POST `/api/auth/sign-out` instead.
*/
@Get('/sign-out')
async signOutDeprecated(
@Res() res: Response,
@Session() session: Session | undefined,
@Query('user_id') userId: string | undefined
) {
res.setHeader('Deprecation', 'true');
if (!session) {
res.status(HttpStatus.OK).send({});
return;
}
await this.auth.signOut(session.sessionId, userId);
await this.auth.refreshCookies(res, session.sessionId);
res.status(HttpStatus.OK).send({});
}
@Public()
@Post('/sign-out')
async signOut(
@Req() req: Request,
@@ -265,14 +184,15 @@ export class AuthController {
return;
}
const csrfCookie = req.cookies?.[AuthService.csrfCookieName] as
| string
| undefined;
if (req.authType === 'jwt') {
await this.auth.signOut(session.sessionId, session.user.id);
res.status(HttpStatus.OK).send({});
return;
}
const csrfCookie = getRequestCookie(req, AuthService.csrfCookieName);
const csrfHeader = req.get('x-affine-csrf-token');
if (
csrfHeader && // optional for backward compatibility, drop after 0.25.0 outdated
(!csrfCookie || csrfCookie !== csrfHeader)
) {
if (!csrfHeader || !csrfCookie || csrfCookie !== csrfHeader) {
throw new ActionForbidden();
}
@@ -286,17 +206,8 @@ export class AuthController {
@UseNamedGuard('version')
@Post('/open-app/sign-in-code')
async openAppSignInCode(@CurrentUser() user?: CurrentUser) {
if (!user) {
throw new ActionForbidden();
}
// short-lived one-time code for handing off the authenticated session
const code = await this.models.verificationToken.create(
TokenType.OpenAppSignIn,
user.id,
5 * 60
);
if (!user) throw new ActionForbidden();
const code = await this.openApp.createSignInCode(user);
return { code };
}
@@ -308,21 +219,21 @@ export class AuthController {
@Res() res: Response,
@Body() credential: OpenAppSignInCredential
) {
if (!credential?.code) {
throw new InvalidAuthState();
}
if (!credential?.code) throw new InvalidAuthState();
const identity = await this.openApp.verifySignInCode(credential.code);
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
res.send({ id: identity.userId, exchangeCode });
}
const tokenRecord = await this.models.verificationToken.get(
TokenType.OpenAppSignIn,
credential.code
);
if (!tokenRecord?.credential) {
throw new InvalidAuthState();
}
await this.auth.setCookies(req, res, tokenRecord.credential);
res.send({ id: tokenRecord.credential });
@Public()
@UseNamedGuard('version')
@Post('/native/exchange')
async exchangeSession(
@Req() req: Request,
@Body() credential: NativeSessionExchangeCredential
) {
if (!credential?.code) throw new InvalidAuthState();
return await this.sessionExchange.exchange(req, credential.code);
}
@Public()
@@ -334,42 +245,11 @@ export class AuthController {
@Body()
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
) {
if (!otp || !email) {
throw new EmailTokenNotFound();
}
if (!otp || !email) throw new EmailTokenNotFound();
validators.assertValidEmail(email);
const consumed = await this.models.magicLinkOtp.consume(
email,
otp,
clientNonce
);
if (!consumed.ok) {
if (consumed.reason === 'nonce_mismatch') {
throw new InvalidAuthState();
}
throw new InvalidEmailToken();
}
const token = consumed.token;
const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn,
token,
{
credential: email,
}
);
if (!tokenRecord) {
throw new InvalidEmailToken();
}
const user = await this.models.user.fulfill(email);
await this.auth.setCookies(req, res, user.id);
res.send({ id: user.id });
const identity = await this.magicLink.verify(email, otp, clientNonce);
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
res.send({ id: identity.userId, exchangeCode });
}
@UseNamedGuard('version')
@@ -377,24 +257,6 @@ export class AuthController {
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
return {
user,
};
}
@Throttle('default', { limit: 1200 })
@Public()
@Get('/sessions')
async currentSessionUsers(@Req() req: Request) {
const token = req.cookies[AuthService.sessionCookieName];
if (!token) {
return {
users: [],
};
}
return {
users: await this.auth.getUserList(token),
};
return { user };
}
}
@@ -0,0 +1,77 @@
import { resolveMx, resolveTxt } from 'node:dns/promises';
const EMAIL_DOMAIN_DNS_TIMEOUT_MS = 2_000;
type DomainLookups = {
resolveMx: typeof resolveMx;
resolveTxt: typeof resolveTxt;
};
const defaultLookups: DomainLookups = {
resolveMx,
resolveTxt,
};
function joinTxtRecords(records: string[][]) {
return records.map(record => record.join(''));
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number) {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error('DNS lookup timed out')),
timeoutMs
);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export async function verifyEmailDomainRecords(
email: string,
lookups: DomainLookups = defaultLookups,
timeoutMs = EMAIL_DOMAIN_DNS_TIMEOUT_MS
) {
const [name, domain, ...rest] = email.split('@');
if (rest.length || !domain || name.includes('+')) {
return false;
}
const [mx, spf, dmarc] = await Promise.allSettled([
withTimeout(
lookups
.resolveMx(domain)
.then(records => records.map(mx => mx.exchange).filter(Boolean)),
timeoutMs
),
withTimeout(
lookups
.resolveTxt(domain)
.then(records =>
joinTxtRecords(records).filter(txt => txt.includes('v=spf1'))
),
timeoutMs
),
withTimeout(
lookups
.resolveTxt('_dmarc.' + domain)
.then(records =>
joinTxtRecords(records).filter(txt => txt.includes('v=DMARC1'))
),
timeoutMs
),
]).then(results =>
results
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
);
return !!mx?.length && !!spf?.length && !!dmarc?.length;
}
+161 -41
View File
@@ -23,6 +23,12 @@ import {
UnsupportedClientVersion,
} from '../../base';
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
import {
extractTokenFromHeader,
getSessionOptionsFromRequest,
SessionIdSchema,
} from './input';
import { isLikelyJwt, JwtSessionService } from './jwt-session';
import { AuthService } from './service';
import { Session, TokenSession } from './session';
@@ -31,9 +37,16 @@ const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
const INTERNAL_ACCESS_TOKEN_TTL_MS = 5 * 60 * 1000;
const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
type AuthenticatedRequestSession =
| { type: 'jwt'; session: Session }
| { type: 'cookie_session'; session: Session }
| { type: 'legacy_bearer_session'; session: Session }
| { type: 'access_token'; token: TokenSession };
@Injectable()
export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
private jwtSession!: JwtSessionService;
private readonly cachedVersionRange = new Map<string, semver.Range | null>();
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)';
@@ -48,6 +61,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
onModuleInit() {
this.auth = this.ref.get(AuthService, { strict: false });
this.jwtSession = this.ref.get(JwtSessionService, { strict: false });
}
async canActivate(context: ExecutionContext) {
@@ -110,12 +124,102 @@ export class AuthGuard implements CanActivate, OnModuleInit {
res?: Response,
isPublic = false
): Promise<Session | TokenSession | null> {
const userSession = await this.signInWithCookie(req, res, isPublic);
if (userSession) {
return userSession;
const result = await this.resolveRequestSession(req, res, isPublic);
return result?.type === 'access_token'
? result.token
: (result?.session ?? null);
}
private async resolveRequestSession(
req: Request,
res?: Response,
isPublic = false
): Promise<AuthenticatedRequestSession | null> {
const bearer = req.headers.authorization
? extractTokenFromHeader(req.headers.authorization)
: undefined;
let ignoredInvalidPublicJwt = false;
if (bearer && isLikelyJwt(bearer)) {
try {
const session = await this.signInWithJwt(req, bearer, res, isPublic);
return session ? { type: 'jwt', session } : null;
} catch (err) {
if (!isPublic) throw err;
ignoredInvalidPublicJwt = true;
}
}
return await this.signInWithAccessToken(req);
if (bearer && !ignoredInvalidPublicJwt) {
// Legacy auth compatibility: old clients may still send opaque session ids as bearer tokens.
const legacyBearerSession = await this.signInWithSessionId(
req,
bearer,
res,
isPublic
);
if (legacyBearerSession) {
return { type: 'legacy_bearer_session', session: legacyBearerSession };
}
const token = await this.signInWithAccessToken(req);
return token ? { type: 'access_token', token } : null;
}
const session = await this.signInWithCookie(req, res, isPublic);
return session ? { type: 'cookie_session', session } : null;
}
async signInWithJwt(
req: Request,
token: string,
res?: Response,
isPublic = false
): Promise<Session | null> {
if (req.session && req.authType === 'jwt') return req.session;
const session = await this.jwtSession.verify(token);
const versionAllowed = await this.checkUserSessionClientVersion(
req,
session,
res,
isPublic
);
if (!versionAllowed) return null;
req.session = session;
req.authType = 'jwt';
return req.session;
}
async signInWithSessionId(
req: Request,
sessionId: string,
res?: Response,
isPublic = false
): Promise<Session | null> {
if (req.session && req.session.sessionId === sessionId) return req.session;
const parsedSessionId = SessionIdSchema.safeParse(sessionId);
if (!parsedSessionId.success) return null;
const { userId } = getSessionOptionsFromRequest(req);
const userSession = await this.auth.getUserSession(
parsedSessionId.data,
userId
);
if (!userSession) return null;
req.session = { ...userSession.session, user: userSession.user };
const versionAllowed = await this.checkUserSessionClientVersion(
req,
req.session,
res,
isPublic
);
if (!versionAllowed) {
req.session = undefined;
return null;
}
req.authType = 'session';
return req.session;
}
async signInWithCookie(
@@ -123,37 +227,24 @@ export class AuthGuard implements CanActivate, OnModuleInit {
res?: Response,
isPublic = false
): Promise<Session | null> {
if (req.session) {
return req.session;
}
if (req.session) return req.session;
// TODO(@forehalo): a cache for user session
const userSession = await this.auth.getUserSessionFromRequest(req, res);
if (userSession) {
const headerClientVersion = getClientVersionFromRequest(req);
if (this.config.client.versionControl.enabled) {
const clientVersion =
headerClientVersion ??
userSession.session.refreshClientVersion ??
userSession.session.signInClientVersion;
req.session = { ...userSession.session, user: userSession.user };
const versionCheckResult = this.checkClientVersion(clientVersion);
if (!versionCheckResult.ok) {
await this.auth.signOut(userSession.session.sessionId);
if (res) {
await this.auth.refreshCookies(res, userSession.session.sessionId);
}
if (isPublic) {
return null;
}
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: versionCheckResult.requiredVersion,
});
}
const versionAllowed = await this.checkUserSessionClientVersion(
req,
req.session,
res,
isPublic
);
if (!versionAllowed) {
req.session = undefined;
return null;
}
if (res) {
@@ -165,10 +256,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
);
}
req.session = {
...userSession.session,
user: userSession.user,
};
req.authType = 'session';
return req.session;
}
@@ -176,6 +264,42 @@ export class AuthGuard implements CanActivate, OnModuleInit {
return null;
}
private async checkUserSessionClientVersion(
req: Request,
session: Session,
res?: Response,
isPublic = false
) {
if (!this.config.client.versionControl.enabled) {
return true;
}
const headerClientVersion = getClientVersionFromRequest(req);
const clientVersion =
headerClientVersion ??
session.refreshClientVersion ??
session.signInClientVersion;
const versionCheckResult = this.checkClientVersion(clientVersion);
if (versionCheckResult.ok) {
return true;
}
await this.auth.signOut(session.sessionId);
if (res) {
await this.auth.refreshCookies(res, session.sessionId);
}
if (isPublic) {
return false;
}
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: versionCheckResult.requiredVersion,
});
}
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
if (req.token) {
return req.token;
@@ -184,10 +308,8 @@ export class AuthGuard implements CanActivate, OnModuleInit {
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
if (tokenSession) {
req.token = {
...tokenSession.token,
user: tokenSession.user,
};
req.token = { ...tokenSession.token, user: tokenSession.user };
req.authType = 'access_token';
return req.token;
}
@@ -280,11 +402,9 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = {
// compatibility with websocket request
parseCookies(upgradeReq);
upgradeReq.cookies = {
[AuthService.sessionCookieName]: handshake.auth.token,
[AuthService.userCookieName]: handshake.auth.userId,
...upgradeReq.cookies,
};
if (handshake.auth.tokenType === 'jwt') {
upgradeReq.headers.authorization = `Bearer ${handshake.auth.token}`;
}
const session = await (async () => {
try {
@@ -0,0 +1,12 @@
export type AuthMethod =
| 'password'
| 'magic_link'
| 'oauth'
| 'open_app'
| 'passkey';
export interface VerifiedIdentity {
userId: string;
method: AuthMethod;
clientVersion?: string;
}
+34 -2
View File
@@ -6,11 +6,18 @@ import { FeatureModule } from '../features';
import { MailModule } from '../mail';
import { QuotaModule } from '../quota';
import { UserModule } from '../user';
import { AuthChallengeStore } from './challenge-store';
import { AuthController } from './controller';
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthCronJob } from './job';
import { JwtSessionService } from './jwt-session';
import { MagicLinkAuthService } from './magic-link';
import { AuthMethodsService } from './methods';
import { SessionExchangeService } from './native-exchange';
import { OpenAppAuthService } from './open-app';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { SessionIssuer } from './session-issuer';
@Module({
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
@@ -18,15 +25,40 @@ import { AuthService } from './service';
AuthService,
AuthResolver,
AuthGuard,
JwtSessionService,
SessionIssuer,
AuthChallengeStore,
MagicLinkAuthService,
OpenAppAuthService,
AuthMethodsService,
SessionExchangeService,
AuthCronJob,
AuthWebsocketOptionsProvider,
],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
exports: [
AuthService,
AuthGuard,
JwtSessionService,
SessionIssuer,
AuthChallengeStore,
MagicLinkAuthService,
OpenAppAuthService,
AuthMethodsService,
SessionExchangeService,
AuthWebsocketOptionsProvider,
],
controllers: [AuthController],
})
export class AuthModule {}
export { AuthChallengeStore } from './challenge-store';
export * from './guard';
export * from './identity';
export * from './input';
export { MagicLinkAuthService } from './magic-link';
export * from './methods';
export { SessionExchangeService };
export { OpenAppAuthService } from './open-app';
export { ClientTokenType } from './resolver';
export { AuthService };
export { AuthService, JwtSessionService, SessionIssuer };
export * from './session';
@@ -0,0 +1,86 @@
import type { Request } from 'express';
import { z } from 'zod';
import { getRequestCookie, getRequestHeader } from '../../base';
export const CLIENT_KIND_HEADER = 'x-affine-client-kind';
export const SESSION_COOKIE_NAME = 'affine_session';
export const USER_COOKIE_NAME = 'affine_user_id';
export const CSRF_COOKIE_NAME = 'affine_csrf_token';
const NativeClientOriginSchema = z
.enum(['capacitor://localhost', 'ionic://localhost', 'https://localhost'])
.optional();
const NativeClientHeadersSchema = z.object({
clientKind: z.literal('native'),
origin: NativeClientOriginSchema,
});
export const BearerHeaderSchema = z
.string()
.regex(/^Bearer\s+\S+$/i)
.transform(value => value.replace(/^Bearer\s+/i, ''));
export function extractTokenFromHeader(authorization: string) {
const parsed = BearerHeaderSchema.safeParse(authorization);
return parsed.success ? parsed.data : undefined;
}
export const SessionIdSchema = z.string().uuid();
export const UserIdSchema = z.union([
z.string().uuid(),
z.string().regex(/^[A-Za-z0-9_-]{1,128}$/),
]);
export const OAuthCallbackBodySchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
client_nonce: z
.string()
.min(1)
.nullish()
.transform(value => value ?? undefined),
});
export const OAuthPreflightBodySchema = z.object({
provider: z.string().min(1),
redirect_uri: z
.string()
.min(1)
.nullish()
.transform(value => value ?? undefined),
client: z
.string()
.min(1)
.nullish()
.transform(value => value ?? undefined),
client_nonce: z.string().min(1),
});
export const OAuthStateEnvelopeSchema = z.object({
state: z.string().min(1),
provider: z.string().min(1).optional(),
});
export function getSessionOptionsFromRequest(req: Request) {
const sessionId = SessionIdSchema.safeParse(
getRequestCookie(req, SESSION_COOKIE_NAME)
);
const userId = UserIdSchema.safeParse(
getRequestCookie(req, USER_COOKIE_NAME)
);
return {
sessionId: sessionId.success ? sessionId.data : undefined,
userId: userId.success ? userId.data : undefined,
};
}
export function isNativeClientRequest(req: Request) {
return NativeClientHeadersSchema.safeParse({
clientKind: getRequestHeader(req, CLIENT_KIND_HEADER),
origin: getRequestHeader(req, 'origin'),
}).success;
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import jwt, { type JwtPayload } from 'jsonwebtoken';
import { AuthenticationRequired, CryptoHelper } from '../../base';
import { Models } from '../../models';
import { sessionUser } from './service';
import type { CurrentUser, Session } from './session';
const JWT_SESSION_TYPE = 'user_session';
const JWT_SESSION_ISSUER = 'affine';
const JWT_SESSION_AUDIENCE = 'affine-client';
const JWT_SESSION_TTL = 15 * 60;
export interface SignedJwtSession {
token: string;
expiresAt: Date;
}
interface UserSessionJwtPayload extends JwtPayload {
sub: string;
sid: string;
typ: typeof JWT_SESSION_TYPE;
}
function isUserSessionJwtPayload(
payload: string | JwtPayload
): payload is UserSessionJwtPayload {
return (
typeof payload !== 'string' &&
typeof payload.sub === 'string' &&
typeof payload.sid === 'string' &&
payload.typ === JWT_SESSION_TYPE
);
}
@Injectable()
export class JwtSessionService {
constructor(
private readonly crypto: CryptoHelper,
private readonly models: Models
) {}
private get currentKey() {
return Buffer.concat([
Buffer.from('affine:user-session-jwt:v1:'),
this.crypto.keyPair.sha256.privateKey,
]);
}
sign(userId: string, sessionId: string): SignedJwtSession {
const expiresAt = new Date(Date.now() + JWT_SESSION_TTL * 1000);
const token = jwt.sign(
{ sid: sessionId, typ: JWT_SESSION_TYPE },
this.currentKey,
{
algorithm: 'HS256',
audience: JWT_SESSION_AUDIENCE,
expiresIn: JWT_SESSION_TTL,
issuer: JWT_SESSION_ISSUER,
subject: userId,
}
);
return { token, expiresAt };
}
async verify(token: string): Promise<Session> {
let payload: string | JwtPayload;
try {
payload = jwt.verify(token, this.currentKey, {
algorithms: ['HS256'],
audience: JWT_SESSION_AUDIENCE,
issuer: JWT_SESSION_ISSUER,
});
} catch {
throw new AuthenticationRequired();
}
if (!isUserSessionJwtPayload(payload)) throw new AuthenticationRequired();
const userSession = await this.models.session
.findUserSessionsBySessionId(payload.sid)
.then(sessions => sessions.find(s => s.userId === payload.sub));
if (!userSession) throw new AuthenticationRequired();
const user = await this.models.user.get(payload.sub);
if (!user) throw new AuthenticationRequired();
return { ...userSession, user: sessionUser(user) as CurrentUser };
}
}
export function isLikelyJwt(token: string) {
return token.split('.').length === 3;
}
@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import {
ActionForbidden,
Config,
CryptoHelper,
InvalidAuthState,
InvalidEmail,
InvalidEmailToken,
SignUpForbidden,
URLHelper,
WrongSignInCredentials,
} from '../../base';
import { Models, TokenType } from '../../models';
import { validators } from '../utils/validators';
import { verifyEmailDomainRecords } from './email-domain';
import type { VerifiedIdentity } from './identity';
import { AuthService } from './service';
@Injectable()
export class MagicLinkAuthService {
private readonly logger = new Logger(MagicLinkAuthService.name);
constructor(
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config,
private readonly crypto: CryptoHelper
) {}
async send(email: string, callbackUrl = '/magic-link', clientNonce?: string) {
validators.assertValidEmail(email);
if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
throw new ActionForbidden();
}
const callbackUrlObj = this.url.url(callbackUrl);
const redirectUriInCallback =
callbackUrlObj.searchParams.get('redirect_uri');
if (
redirectUriInCallback &&
!this.url.isAllowedRedirectUri(redirectUriInCallback)
) {
throw new ActionForbidden();
}
const user = await this.models.user.getUserByEmail(email, {
withDisabled: true,
});
if (!user) {
await this.assertSignupAllowed(email);
} else if (user.disabled) {
throw new WrongSignInCredentials({ email });
}
const ttlInSec = 30 * 60;
const token = await this.models.verificationToken.create(
TokenType.SignIn,
email,
ttlInSec
);
const otp = this.crypto.otp();
await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce);
const magicLink = this.url.link(callbackUrl, { token: otp, email });
if (env.dev) {
this.logger.debug(`Magic link: ${magicLink}`);
}
await this.auth.sendSignInEmail(email, magicLink, otp, !user);
return { email };
}
async verify(
email: string,
otp: string,
clientNonce?: string
): Promise<VerifiedIdentity> {
validators.assertValidEmail(email);
const consumed = await this.models.magicLinkOtp.consume(
email,
otp,
clientNonce
);
if (!consumed.ok) {
if (consumed.reason === 'nonce_mismatch') {
throw new InvalidAuthState();
}
throw new InvalidEmailToken();
}
const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn,
consumed.token,
{
credential: email,
}
);
if (!tokenRecord) {
throw new InvalidEmailToken();
}
const user = await this.models.user.fulfill(email);
return { userId: user.id, method: 'magic_link' };
}
private async assertSignupAllowed(email: string) {
if (!this.config.auth.allowSignup) {
throw new SignUpForbidden();
}
if (!this.config.auth.requireEmailDomainVerification) {
return;
}
if (!(await verifyEmailDomainRecords(email))) {
throw new InvalidEmail({ email });
}
}
}
@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { Config } from '../../base';
import { Models, type User } from '../../models';
import { verifyEmailDomainRecords } from './email-domain';
export const AUTH_OAUTH_PROVIDER_READER = Symbol('AUTH_OAUTH_PROVIDER_READER');
interface OAuthProviderReader {
providers: string[];
}
export interface LoginAuthMethods {
password: { available: boolean };
magicLink: { available: boolean };
oauth: { available: boolean; providers: string[] };
passkey: { available: boolean; discoverable: boolean };
}
export interface BoundAuthMethods {
password: { bound: boolean };
oauth: { bound: boolean; providers: string[] };
passkey: { bound: boolean; count: number };
}
@Injectable()
export class AuthMethodsService {
constructor(
private readonly config: Config,
private readonly models: Models,
private readonly db: PrismaClient,
private readonly ref: ModuleRef
) {}
async loginPreflight(email: string) {
const [user, userWithDisabled] = await Promise.all([
this.models.user.getUserByEmail(email),
this.models.user.getUserByEmail(email, {
withDisabled: true,
}),
]);
const disabledUser =
userWithDisabled?.disabled && !user ? userWithDisabled : null;
const providers = this.oauthProviders();
return {
registered: !!user?.registered,
methods: {
password: {
available:
!!user?.password &&
!user.disabled &&
(await this.canPasswordSignIn(email)),
},
magicLink: {
available: await this.canMagicLinkSignIn(email, user, disabledUser),
},
oauth: {
available: providers.length > 0,
providers,
},
passkey: {
available: false,
discoverable: false,
},
} satisfies LoginAuthMethods,
};
}
async boundMethods(userId: string): Promise<BoundAuthMethods> {
const [user, connectedAccounts] = await Promise.all([
this.models.user.get(userId),
this.db.connectedAccount.findMany({
select: { provider: true },
where: { userId },
}),
]);
const providers = Array.from(
new Set(connectedAccounts.map(account => account.provider))
);
return {
password: { bound: !!user?.password },
oauth: { bound: providers.length > 0, providers },
passkey: { bound: false, count: 0 },
};
}
private async canPasswordSignIn(_email: string) {
return true;
}
private async canMagicLinkSignIn(
email: string,
user: User | null,
disabledUser: User | null
) {
if (disabledUser) {
return false;
}
if (user) {
return !user.disabled;
}
if (!this.config.auth.allowSignup) {
return false;
}
return this.emailDomainAllowed(email);
}
private async emailDomainAllowed(email: string) {
if (!this.config.auth.requireEmailDomainVerification) {
return true;
}
return verifyEmailDomainRecords(email);
}
private oauthProviders() {
try {
const reader = this.ref.get<OAuthProviderReader>(
AUTH_OAUTH_PROVIDER_READER,
{ strict: false }
);
return reader.providers;
} catch {
return [];
}
}
}
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { ActionForbidden, InvalidAuthState } from '../../base';
import { AuthChallengeStore } from './challenge-store';
import { isNativeClientRequest } from './input';
import { JwtSessionService } from './jwt-session';
import { AuthService } from './service';
interface SessionExchangePayload {
userId: string;
sessionId: string;
}
@Injectable()
export class SessionExchangeService {
constructor(
private readonly auth: AuthService,
private readonly challenges: AuthChallengeStore,
private readonly jwtSession: JwtSessionService
) {}
async createCode(req: Request, userId: string, sessionId: string) {
if (!isNativeClientRequest(req)) {
return;
}
return this.challenges.create<SessionExchangePayload>(
'native_session_exchange',
{ userId, sessionId },
60 * 1000
);
}
async exchange(req: Request, code: string) {
if (!isNativeClientRequest(req)) {
throw new ActionForbidden();
}
const payload = await this.challenges.consume<SessionExchangePayload>(
'native_session_exchange',
code
);
if (!payload?.userId || !payload.sessionId) {
throw new InvalidAuthState();
}
const session = await this.auth.getUserSession(
payload.sessionId,
payload.userId
);
if (!session) {
throw new InvalidAuthState();
}
return this.jwtSession.sign(payload.userId, payload.sessionId);
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InvalidAuthState } from '../../base';
import { AuthChallengeStore } from './challenge-store';
import type { VerifiedIdentity } from './identity';
import type { CurrentUser } from './session';
@Injectable()
export class OpenAppAuthService {
constructor(private readonly challenges: AuthChallengeStore) {}
async createSignInCode(user: CurrentUser) {
return this.challenges.create(
'open_app_sign_in',
{ userId: user.id },
5 * 60 * 1000
);
}
async verifySignInCode(code: string): Promise<VerifiedIdentity> {
const payload = await this.challenges.consume<{ userId?: string }>(
'open_app_sign_in',
code
);
if (!payload?.userId) {
throw new InvalidAuthState();
}
return { userId: payload.userId, method: 'open_app' };
}
}
@@ -63,7 +63,7 @@ export class AuthResolver {
@ResolveField(() => ClientTokenType, {
name: 'token',
deprecationReason: 'use [/api/auth/sign-in?native=true] instead',
deprecationReason: 'use native session exchange instead',
})
async clientToken(
@CurrentUser() currentUser: CurrentUser,
@@ -4,14 +4,18 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es';
import {
Config,
getClientVersionFromRequest,
SignUpForbidden,
} from '../../base';
import { Config, SignUpForbidden } from '../../base';
import { Models, type User, type UserSession } from '../../models';
import { Mailer } from '../mail/mailer';
import { createDevUsers } from './dev';
import type { VerifiedIdentity } from './identity';
import {
CSRF_COOKIE_NAME,
extractTokenFromHeader,
getSessionOptionsFromRequest,
SESSION_COOKIE_NAME,
USER_COOKIE_NAME,
} from './input';
import type { CurrentUser } from './session';
export function sessionUser(
@@ -27,20 +31,12 @@ export function sessionUser(
});
}
function extractTokenFromHeader(authorization: string) {
if (!/^Bearer\s/i.test(authorization)) {
return;
}
return authorization.substring(7);
}
@Injectable()
export class AuthService implements OnApplicationBootstrap {
readonly cookieOptions: CookieOptions;
static readonly sessionCookieName = 'affine_session';
static readonly userCookieName = 'affine_user_id';
static readonly csrfCookieName = 'affine_csrf_token';
static readonly sessionCookieName = SESSION_COOKIE_NAME;
static readonly userCookieName = USER_COOKIE_NAME;
static readonly csrfCookieName = CSRF_COOKIE_NAME;
constructor(
private readonly config: Config,
@@ -90,6 +86,14 @@ export class AuthService implements OnApplicationBootstrap {
return this.models.user.signIn(email, password).then(sessionUser);
}
async verifyPassword(
email: string,
password: string
): Promise<VerifiedIdentity> {
const user = await this.models.user.signIn(email, password);
return { userId: user.id, method: 'password' };
}
async signOut(sessionId: string, userId?: string) {
// sign out all users in the session
if (!userId) {
@@ -104,10 +108,7 @@ export class AuthService implements OnApplicationBootstrap {
userId?: string
): Promise<{ user: CurrentUser; session: UserSession } | null> {
const sessions = await this.getUserSessions(sessionId);
if (!sessions.length) {
return null;
}
if (!sessions.length) return null;
let userSession: UserSession | undefined;
@@ -201,55 +202,6 @@ export class AuthService implements OnApplicationBootstrap {
return await this.models.session.deleteUserSessions(userId);
}
getSessionOptionsFromRequest(req: Request) {
let sessionId: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionId && req.headers.authorization) {
sessionId = extractTokenFromHeader(req.headers.authorization);
}
const userId: string | undefined =
req.cookies[AuthService.userCookieName] ||
req.headers[AuthService.userCookieName.replaceAll('_', '-')];
return {
sessionId,
userId,
};
}
async setCookies(
req: Request,
res: Response,
userId: string,
clientVersion?: string
) {
const { sessionId } = this.getSessionOptionsFromRequest(req);
const signInClientVersion =
clientVersion ?? getClientVersionFromRequest(req);
const userSession = await this.createUserSession(
userId,
sessionId,
undefined,
signInClientVersion
);
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
...this.cookieOptions,
expires: userSession.expiresAt ?? void 0,
});
res.cookie(AuthService.csrfCookieName, randomUUID(), {
...this.cookieOptions,
httpOnly: false,
expires: userSession.expiresAt ?? void 0,
});
this.setUserCookie(res, userId);
}
async refreshCookies(res: Response, sessionId?: string) {
if (sessionId) {
const users = await this.getUserList(sessionId);
@@ -264,7 +216,7 @@ export class AuthService implements OnApplicationBootstrap {
this.clearCookies(res);
}
private clearCookies(res: Response<any, Record<string, any>>) {
clearCookies(res: Response<any, Record<string, any>>) {
res.clearCookie(AuthService.sessionCookieName);
res.clearCookie(AuthService.userCookieName);
res.clearCookie(AuthService.csrfCookieName);
@@ -281,12 +233,8 @@ export class AuthService implements OnApplicationBootstrap {
}
async getUserSessionFromRequest(req: Request, res?: Response) {
const { sessionId, userId } = this.getSessionOptionsFromRequest(req);
if (!sessionId) {
return null;
}
const { sessionId, userId } = getSessionOptionsFromRequest(req);
if (!sessionId) return null;
const session = await this.getUserSession(sessionId, userId);
if (res) {
@@ -0,0 +1,73 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { getClientVersionFromRequest, getRequestCookie } from '../../base';
import type { VerifiedIdentity } from './identity';
import { isNativeClientRequest } from './input';
import { SessionExchangeService } from './native-exchange';
import { AuthService } from './service';
export type IssuedSession = {
userId: string;
sessionId: string;
exchangeCode?: string;
};
@Injectable()
export class SessionIssuer {
constructor(
private readonly auth: AuthService,
private readonly sessionExchange: SessionExchangeService
) {}
async issue(
req: Request,
res: Response,
identity: VerifiedIdentity
): Promise<IssuedSession> {
const nativeClient = isNativeClientRequest(req);
const sessionId =
req.authType === 'jwt'
? req.session?.sessionId
: getRequestCookie(req, AuthService.sessionCookieName);
const signInClientVersion =
identity.clientVersion ?? getClientVersionFromRequest(req);
const userSession = await this.auth.createUserSession(
identity.userId,
sessionId,
undefined,
signInClientVersion
);
if (nativeClient) {
this.auth.clearCookies(res);
} else {
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
...this.auth.cookieOptions,
expires: userSession.expiresAt ?? void 0,
});
res.cookie(AuthService.csrfCookieName, randomUUID(), {
...this.auth.cookieOptions,
httpOnly: false,
expires: userSession.expiresAt ?? void 0,
});
this.auth.setUserCookie(res, identity.userId);
}
const exchangeCode = await this.sessionExchange.createCode(
req,
identity.userId,
userSession.sessionId
);
return {
userId: identity.userId,
sessionId: userSession.sessionId,
exchangeCode,
};
}
}
@@ -10,7 +10,7 @@ import {
UseNamedGuard,
} from '../../base';
import { Models } from '../../models';
import { AuthService, Public } from '../auth';
import { Public, SessionIssuer } from '../auth';
import { ServerService } from '../config';
import { validators } from '../utils/validators';
@@ -26,7 +26,7 @@ export class CustomSetupController {
constructor(
private readonly config: Config,
private readonly models: Models,
private readonly auth: AuthService,
private readonly sessionIssuer: SessionIssuer,
private readonly mutex: Mutex,
private readonly server: ServerService
) {}
@@ -72,7 +72,10 @@ export class CustomSetupController {
'selfhost setup'
);
await this.auth.setCookies(req, res, user.id);
await this.sessionIssuer.issue(req, res, {
userId: user.id,
method: 'password',
});
res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) {
await this.models.user.delete(user.id);
+1
View File
@@ -2,6 +2,7 @@ declare namespace Express {
interface Request {
session?: import('./core/auth/session').Session;
token?: import('./core/auth/session').TokenSession;
authType?: 'jwt' | 'session' | 'access_token';
}
}
@@ -13,8 +13,6 @@ export enum TokenType {
VerifyEmail,
ChangeEmail,
ChangePassword,
Challenge,
OpenAppSignIn,
}
@Injectable()
@@ -12,7 +12,7 @@ import {
OnEvent,
} from '../../base';
import { ServerFeature, ServerService } from '../../core';
import { Models, TokenType } from '../../models';
import { AuthChallengeStore } from '../../core/auth';
import { verifyChallengeResponse } from '../../native';
import { CaptchaConfig } from './types';
@@ -28,7 +28,7 @@ export class CaptchaService {
constructor(
private readonly config: Config,
private readonly models: Models,
private readonly challenges: AuthChallengeStore,
private readonly server: ServerService
) {
this.captcha = config.captcha.config;
@@ -93,10 +93,10 @@ export class CaptchaService {
async getChallengeToken() {
const resource = randomUUID();
const challenge = await this.models.verificationToken.create(
TokenType.Challenge,
const challenge = await this.challenges.create(
'captcha',
resource,
5 * 60
5 * 60 * 1000
);
return {
@@ -117,9 +117,7 @@ export class CaptchaService {
const challenge = credential.challenge;
let resource: string | null = null;
if (typeof challenge === 'string' && challenge) {
resource = await this.models.verificationToken
.get(TokenType.Challenge, challenge)
.then(token => token?.credential || null);
resource = await this.challenges.consume<string>('captcha', challenge);
}
if (resource) {
@@ -3,68 +3,63 @@ import {
Controller,
HttpCode,
HttpStatus,
Logger,
Post,
type RawBodyRequest,
Req,
Res,
} from '@nestjs/common';
import { ConnectedAccount } from '@prisma/client';
import type { Request, Response } from 'express';
import {
ActionForbidden,
Config,
getClientVersionFromRequest,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider,
URLHelper,
UseNamedGuard,
} from '../../base';
import { AuthService, Public } from '../../core/auth';
import { Models } from '../../models';
import {
OAuthCallbackBodySchema,
OAuthPreflightBodySchema,
Public,
SessionIssuer,
} from '../../core/auth';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthService } from './service';
@Controller('/api/oauth')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor(
private readonly auth: AuthService,
private readonly sessionIssuer: SessionIssuer,
private readonly oauth: OAuthService,
private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory,
private readonly url: URLHelper,
private readonly config: Config
private readonly url: URLHelper
) {}
@Public()
@UseNamedGuard('version')
@Post('/preflight')
@HttpCode(HttpStatus.OK)
async preflight(
@Req() req: Request,
@Body('provider') unknownProviderName?: keyof typeof OAuthProviderName,
@Body('redirect_uri') redirectUri?: string,
@Body('client') client?: string,
@Body('client_nonce') clientNonce?: string
) {
if (!unknownProviderName) {
async preflight(@Req() req: Request, @Body() body?: unknown) {
const input = OAuthPreflightBodySchema.safeParse(body);
if (!input.success) {
const fields = new Set(input.error.issues.map(issue => issue.path[0]));
if (fields.has('client_nonce')) {
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
}
throw new MissingOauthQueryParameter({ name: 'provider' });
}
if (!clientNonce) {
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
}
const providerName = OAuthProviderName[unknownProviderName];
const {
provider: unknownProviderName,
redirect_uri: redirectUri,
client,
client_nonce: clientNonce,
} = input.data;
const providerName =
OAuthProviderName[unknownProviderName as keyof typeof OAuthProviderName];
const provider = this.providerFactory.get(providerName);
if (!provider) {
@@ -123,57 +118,38 @@ export class OAuthController {
async callback(
@Req() req: RawBodyRequest<Request>,
@Res() res: Response,
@Body('code') code?: string,
@Body('state') stateStr?: string,
@Body('client_nonce') clientNonce?: string
@Body() body?: unknown
) {
// TODO(@forehalo): refactor and remove deprecated code in 0.23
if (!code) {
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (!stateStr) {
const input = OAuthCallbackBodySchema.safeParse(body);
if (!input.success) {
const fields = new Set(input.error.issues.map(issue => issue.path[0]));
if (fields.has('code')) {
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (fields.has('state')) {
throw new MissingOauthQueryParameter({ name: 'state' });
}
throw new MissingOauthQueryParameter({ name: 'state' });
}
// NOTE(@forehalo): Apple sign in will directly post /callback, with `state` set at #L73
let rawState = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
rawState = JSON.parse(stateStr);
stateStr = rawState.state;
} catch {
/* noop */
}
}
const { code, state: stateStr, client_nonce: clientNonce } = input.data;
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const verified = await this.oauth.verifyCallback({
code,
stateStr,
clientNonce,
rawBody: req.rawBody,
});
const state = await this.oauth.getOAuthState(stateStr);
if (!state) {
throw new OauthStateExpired();
}
if (!state.token) {
state.token = stateStr;
}
if (
state.provider === OAuthProviderName.Apple &&
rawState &&
state.client &&
state.client !== 'web'
) {
const clientUrl = new URL(`${state.client}://authentication`);
if (verified.type === 'handoff') {
const clientUrl = new URL(`${verified.state.client}://authentication`);
clientUrl.searchParams.set('method', 'oauth');
clientUrl.searchParams.set(
'payload',
JSON.stringify({
state: stateStr,
state: verified.stateToken,
code,
provider: rawState.provider,
provider: verified.provider,
})
);
clientUrl.searchParams.set('server', this.url.requestOrigin);
@@ -185,46 +161,8 @@ export class OAuthController {
);
}
if (!state.provider) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
if (
state.provider !== OAuthProviderName.Apple &&
(!clientNonce || !state.clientNonce || state.clientNonce !== clientNonce)
) {
throw new InvalidAuthState();
}
let tokens: Tokens;
try {
tokens = await provider.getToken(code, state);
} catch (err) {
let rayBodyString = '';
if (req.rawBody) {
// only log the first 4096 bytes of the raw body
rayBodyString = req.rawBody.subarray(0, 4096).toString('utf-8');
}
this.logger.warn(
`Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rayBodyString}, error: ${err}`
);
throw err;
}
const externAccount = await provider.getUser(tokens, state);
const user = await this.getOrCreateUserFromOauth(
state.provider,
externAccount,
tokens
);
await this.auth.setCookies(req, res, user.id, state.clientVersion);
const { identity, state } = verified;
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
if (
state.provider === OAuthProviderName.Apple &&
@@ -234,96 +172,9 @@ export class OAuthController {
}
res.send({
id: user.id,
id: identity.userId,
exchangeCode,
redirectUri: state.redirectUri,
});
}
private async getOrCreateUserFromOauth(
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
// already connected
await this.updateConnectedAccount(connectedAccount, tokens);
if (
!connectedAccount.user.emailVerifiedAt &&
// external email may change, check if it matches exists email
externalAccount.email.toLowerCase() ===
connectedAccount.user.email.toLowerCase()
) {
await this.auth.setEmailVerified(connectedAccount.userId);
}
return connectedAccount.user;
}
if (!this.config.auth.allowSignupForOauth) {
throw new SignUpForbidden();
}
const user = await this.models.user.fulfill(externalAccount.email, {
name: externalAccount.name,
avatarUrl: externalAccount.avatarUrl,
});
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
return user;
}
private async updateConnectedAccount(
connectedAccount: ConnectedAccount,
tokens: Tokens
) {
return await this.models.user.updateConnectedAccount(connectedAccount.id, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
/**
* we currently don't support connect oauth account to existing user
* keep it incase we need it in the future
*/
// @ts-expect-error allow unused
private async _connectAccount(
user: { id: string },
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
if (connectedAccount.userId !== user.id) {
throw new OauthAccountAlreadyConnected();
}
} else {
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
}
}
@@ -3,7 +3,7 @@ import './config';
import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core';
import { AuthModule } from '../../core/auth';
import { AUTH_OAUTH_PROVIDER_READER, AuthModule } from '../../core/auth';
import { UserModule } from '../../core/user';
import { OAuthController } from './controller';
import { OAuthProviderFactory } from './factory';
@@ -15,6 +15,7 @@ import { OAuthService } from './service';
imports: [AuthModule, UserModule, ServerConfigModule],
providers: [
OAuthProviderFactory,
{ provide: AUTH_OAUTH_PROVIDER_READER, useExisting: OAuthProviderFactory },
OAuthService,
OAuthResolver,
...OAuthProviders,
@@ -1,18 +1,57 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccount } from '@prisma/client';
import { SessionCache } from '../../base';
import {
Config,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider,
} from '../../base';
import {
AuthChallengeStore,
AuthService,
OAuthStateEnvelopeSchema,
type VerifiedIdentity,
} from '../../core/auth';
import { Models } from '../../models';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthPkceChallenge, OAuthState } from './types';
const OAUTH_STATE_KEY = 'OAUTH_STATE';
type HandoffResult = {
type: 'handoff';
code: string;
provider: unknown;
state: OAuthState;
stateToken: string;
};
type IdentityResult = {
type: 'identity';
identity: VerifiedIdentity;
state: OAuthState;
};
type VerifyCallbackResult = HandoffResult | IdentityResult;
const OAUTH_STATE_TTL_MS = 3600 * 3 * 1000;
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
private readonly providerFactory: OAuthProviderFactory,
private readonly cache: SessionCache
private readonly challenges: AuthChallengeStore,
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config
) {}
isValidState(stateStr: string) {
@@ -20,23 +59,191 @@ export class OAuthService {
}
async saveOAuthState(state: OAuthState) {
const token = randomUUID();
const payload: OAuthState = { ...state, token };
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, {
ttl: 3600 * 3 * 1000 /* 3 hours */,
});
return token;
return this.challenges.create<OAuthState>(
'oauth_state',
token => ({ ...state, token }),
OAUTH_STATE_TTL_MS
);
}
async getOAuthState(token: string) {
return this.cache.get<OAuthState>(`${OAUTH_STATE_KEY}:${token}`);
return this.challenges.get<OAuthState>('oauth_state', token);
}
availableOAuthProviders() {
return this.providerFactory.providers;
}
async verifyCallback(input: {
code: string;
stateStr: string;
clientNonce?: string;
rawBody?: Buffer;
}): Promise<VerifyCallbackResult> {
let stateStr = input.stateStr;
let rawState: { state: string; provider?: string } | null = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
const parsed = OAuthStateEnvelopeSchema.safeParse(JSON.parse(stateStr));
if (parsed.success) {
rawState = parsed.data;
stateStr = rawState.state;
}
} catch {} // noop
}
if (typeof stateStr !== 'string' || !this.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const state = await this.getOAuthState(stateStr);
if (!state) throw new OauthStateExpired();
if (!state.token) state.token = stateStr;
if (
state.provider === OAuthProviderName.Apple &&
rawState &&
state.client &&
state.client !== 'web'
) {
return {
type: 'handoff',
code: input.code,
provider: rawState.provider,
state,
stateToken: stateStr,
};
}
if (!state.provider) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
if (
state.provider !== OAuthProviderName.Apple &&
(!input.clientNonce ||
!state.clientNonce ||
state.clientNonce !== input.clientNonce)
) {
throw new InvalidAuthState();
}
return {
type: 'identity',
identity: await this.verifyCallbackIdentity(
input.code,
state,
stateStr,
input.rawBody
),
state,
};
}
async verifyCallbackIdentity(
code: string,
state: OAuthState,
stateStr: string,
rawBody?: Buffer
): Promise<VerifiedIdentity> {
if (!state.provider) {
throw new UnknownOauthProvider({ name: 'unknown' });
}
const provider = this.providerFactory.get(state.provider);
if (!provider) {
throw new UnknownOauthProvider({ name: state.provider });
}
let tokens: Tokens;
try {
tokens = await provider.getToken(code, state);
} catch (err) {
const rawBodyString = rawBody
? rawBody.subarray(0, 4096).toString('utf-8')
: '';
this.logger.warn(
`Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rawBodyString}, error: ${err}`
);
throw err;
}
const externalAccount = await provider.getUser(tokens, state);
const user = await this.getOrCreateUserFromOauth(
state.provider,
externalAccount,
tokens
);
return {
userId: user.id,
method: 'oauth',
clientVersion: state.clientVersion,
};
}
private async getOrCreateUserFromOauth(
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
) {
const connectedAccount = await this.models.user.getConnectedAccount(
provider,
externalAccount.id
);
if (connectedAccount) {
await this.updateConnectedAccount(connectedAccount, tokens);
if (
!connectedAccount.user.emailVerifiedAt &&
externalAccount.email.toLowerCase() ===
connectedAccount.user.email.toLowerCase()
) {
await this.auth.setEmailVerified(connectedAccount.userId);
}
return connectedAccount.user;
}
if (!this.config.auth.allowSignupForOauth) {
throw new SignUpForbidden();
}
const user = await this.models.user.fulfill(externalAccount.email, {
name: externalAccount.name,
avatarUrl: externalAccount.avatarUrl,
});
await this.models.user.createConnectedAccount({
userId: user.id,
provider,
providerAccountId: externalAccount.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
return user;
}
private async updateConnectedAccount(
connectedAccount: ConnectedAccount,
tokens: Tokens
) {
return await this.models.user.updateConnectedAccount(connectedAccount.id, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
});
}
createPkcePair(): OAuthPkceChallenge {
const codeVerifier = this.randomBase64Url(96);
const hash = createHash('sha256').update(codeVerifier).digest();
+1 -1
View File
@@ -2679,7 +2679,7 @@ type UserType {
"""Get user settings"""
settings: UserSettingsType!
subscriptions: [SubscriptionType!]!
token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead")
token: tokenType! @deprecated(reason: "use native session exchange instead")
}
type ValidationErrorDataType {