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 type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import supertest from 'supertest';
import { import {
changeEmail, changeEmail,
@@ -33,6 +34,10 @@ test('change email', async t => {
const u2Email = 'u2@affine.pro'; const u2Email = 'u2@affine.pro';
const user = await app.signupV1(u1Email); 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'); await sendChangeEmail(app, u1Email, '/email-change');
const changeMail = app.mails.last('ChangeEmail'); const changeMail = app.mails.last('ChangeEmail');
@@ -77,7 +82,16 @@ test('change email', async t => {
t.is(changedMail.to, u2Email); t.is(changedMail.to, u2Email);
t.is(changedMail.props.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({ await app.login({
...user, ...user,
email: u2Email, 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 { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { ExecutionContext, TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import supertest from 'supertest'; 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 { AuthService } from '../../core/auth/service';
import { import {
createTestingApp, createTestingApp,
@@ -18,7 +24,9 @@ import {
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
magicLink: MagicLinkAuthService;
db: PrismaClient; db: PrismaClient;
config: ConfigFactory;
app: TestingApp; app: TestingApp;
}>; }>;
@@ -26,13 +34,18 @@ test.before(async t => {
const app = await createTestingApp(); const app = await createTestingApp();
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.magicLink = app.get(MagicLinkAuthService);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
t.context.app = app; t.context.app = app;
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
Sinon.reset(); Sinon.reset();
await t.context.app.initTestingDB(); await t.context.app.initTestingDB();
t.context.config.override({
auth: { allowSignup: true, requireEmailDomainVerification: false },
});
}); });
test.after.always(async t => { 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'); const u1 = await app.createUser('u1@affine.pro');
await app const res = await app
.POST('/api/auth/sign-in') .POST('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password }) .send({ email: u1.email, password: u1.password })
.expect(200); .expect(200);
t.is(res.body.id, u1.id);
t.falsy(res.body.token);
t.falsy(res.body.expiresAt);
const session = await currentUser(app); const session = await currentUser(app);
t.is(session?.id, u1.id); 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 => { test('should record sign in client version when header is provided', async t => {
const { app, db } = t.context; 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'); 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 => { test('should be able to sign in with email', async t => {
const { app } = t.context; 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 email = url.searchParams.get('email');
const token = url.searchParams.get('token'); 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); const session = await currentUser(app);
t.is(session?.id, u1.id); 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: '); 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 => { test('should not be able to sign in if forbidden', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
@@ -202,7 +445,7 @@ test('should be able to sign out', async t => {
t.falsy(session); 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 { app } = t.context;
const u1 = await app.createUser('u1@affine.pro'); 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()) await supertest(app.getHttpServer())
.post('/api/auth/sign-out') .post('/api/auth/sign-out')
.set('Cookie', cookieHeader) .set('Cookie', cookieHeader)
.expect(200); .expect(HttpStatus.FORBIDDEN);
const sessionRes = await supertest(app.getHttpServer()) const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session') .get('/api/auth/session')
.set('Cookie', cookieHeader) .set('Cookie', cookieHeader)
.expect(200); .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); 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 => { test('should be able to sign out when duplicated csrf cookies exist', async t => {
const { app } = t.context; 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); 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 => { test('should reject sign out when csrf token mismatched', async t => {
const { app } = t.context; 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()) const exchangeRes = await supertest(app.getHttpServer())
.post('/api/auth/open-app/sign-in') .post('/api/auth/open-app/sign-in')
.set('x-affine-client-kind', 'native')
.send({ code }) .send({ code })
.expect(201); .expect(201);
const exchangedCookies = exchangeRes.get('Set-Cookie') ?? []; t.is(exchangeRes.body.id, u1.id);
t.true( t.truthy(exchangeRes.body.exchangeCode);
exchangedCookies.some(c => assertClearsNativeAuthCookies(t, exchangeRes);
c.startsWith(`${AuthService.sessionCookieName}=`) 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()) const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session') .get('/api/auth/session')
.set('Cookie', cookieHeader) .set('Authorization', `Bearer ${tokenRes.body.token}`)
.expect(200); .expect(200);
t.is(sessionRes.body.user?.id, u1.id); 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); 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 // multiple accounts session tests
test('should be able to sign in another account in one session', async t => { test('should be able to sign in another account in one session', async t => {
const { app } = t.context; 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 }) .send({ email: u2.email, password: u2.password })
.expect(200); .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 // default to latest signed in user: u2
let session = await app.GET('/api/auth/session').expect(200); 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 request from 'supertest';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base'; 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 { AuthService } from '../../core/auth/service';
import { Models } from '../../models'; import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils'; import { createTestingApp, TestingApp } from '../utils';
@@ -37,6 +43,7 @@ const test = ava as TestFn<{
app: TestingApp; app: TestingApp;
server: any; server: any;
auth: AuthService; auth: AuthService;
jwtSession: JwtSessionService;
models: Models; models: Models;
db: PrismaClient; db: PrismaClient;
config: ConfigFactory; config: ConfigFactory;
@@ -53,6 +60,7 @@ test.before(async t => {
t.context.app = app; t.context.app = app;
t.context.server = app.getHttpServer(); t.context.server = app.getHttpServer();
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.jwtSession = app.get(JwtSessionService);
t.context.models = app.get(Models); t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory); 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); 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) const res = await request(t.context.server)
.get('/private') .get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) .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); t.is(res.body.user.id, t.context.u1.id);
}); });
test('should be able to visit private api with access token', async t => { test('should be able to visit private api with legacy bearer session id', async t => {
const models = t.context.app.get(Models); const res = await request(t.context.server)
const token = await models.accessToken.create({ .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, userId: t.context.u1.id,
name: 'test', name: 'test',
}); });
const res = await request(t.context.server) const res = await request(t.context.server)
.get('/private') .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); .expect(HttpStatus.OK);
t.is(res.body.user.id, t.context.u1.id); 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); 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 => { test('should be able to incr/decr number cache', async t => {
t.true(await cache.set(key('test-incr'), 1)); t.true(await cache.set(key('test-incr'), 1));
t.is(await cache.increase(key('test-incr')), 2); t.is(await cache.increase(key('test-incr')), 2);
@@ -63,6 +63,14 @@ export class TestingApp extends NestApplication {
await this.close(); await this.close();
} }
clearAuth() {
this.resetRateLimit();
this.sessionCookie = null;
this.currentUserCookie = null;
this.csrfCookie = null;
this.userCookies.clear();
}
request( request(
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch', method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string path: string
@@ -7,7 +7,7 @@ import { app, e2e, Mockers } from '../test';
e2e('user(email) should return null without auth', async t => { e2e('user(email) should return null without auth', async t => {
const user = await app.create(Mockers.User); const user = await app.create(Mockers.User);
await app.logout(); app.clearAuth();
const res = await app.gql({ const res = await app.gql({
query: getUserQuery, 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 => { e2e('user(email) should return null outside workspace scope', async t => {
await app.logout(); app.clearAuth();
const me = await app.signup(); const me = await app.signup();
const other = await app.create(Mockers.User); 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 => { e2e('user(email) should return user within workspace scope', async t => {
await app.logout(); app.clearAuth();
const me = await app.signup(); const me = await app.signup();
const other = await app.create(Mockers.User); const other = await app.create(Mockers.User);
const ws = await app.create(Mockers.Workspace, { owner: me }); 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 => { e2e('user(email) should be rate limited', async t => {
await app.logout(); app.clearAuth();
const me = await app.signup(); const me = await app.signup();
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({ const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
@@ -7,6 +7,7 @@ import Sinon from 'sinon';
import { AppModule } from '../../app.module'; import { AppModule } from '../../app.module';
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base'; import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
import { SessionCache } from '../../base/cache';
import { ConfigModule } from '../../base/config'; import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
@@ -23,6 +24,7 @@ import { createTestingApp, currentUser, TestingApp } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
oauth: OAuthService; oauth: OAuthService;
cache: SessionCache;
models: Models; models: Models;
u1: CurrentUser; u1: CurrentUser;
db: PrismaClient; db: PrismaClient;
@@ -62,6 +64,7 @@ test.before(async t => {
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService); t.context.oauth = app.get(OAuthService);
t.context.cache = app.get(SessionCache);
t.context.models = app.get(Models); t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);
t.context.app = app; 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 => { test('should throw if client_nonce is missing in preflight', async t => {
const { app } = t.context; const { app } = t.context;
app.clearAuth();
await app await app
.POST('/api/oauth/preflight') .POST('/api/oauth/preflight')
@@ -293,6 +297,19 @@ test('should be able to save oauth state', async t => {
t.is(state!.provider, OAuthProviderName.Google); 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 => { test('should be able to get registered oauth providers', async t => {
const { oauth } = t.context; 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'); const clientNonce = mockOAuthProvider(app, 'u2@affine.pro');
await app const res = await app
.POST('/api/oauth/callback') .POST('/api/oauth/callback')
.set('x-affine-client-kind', 'native')
.send({ code: '1', state: '1', client_nonce: clientNonce }) .send({ code: '1', state: '1', client_nonce: clientNonce })
.expect(HttpStatus.OK); .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.truthy(sessionUser);
t.is(sessionUser!.email, 'u2@affine.pro'); 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, { return io(url, {
transports: ['websocket'], transports: ['websocket'],
reconnection: false, reconnection: false,
forceNew: true, forceNew: true,
extraHeaders: { ...(cookie ? { extraHeaders: { cookie } } : {}),
cookie, ...(auth ? { auth } : {}),
},
}); });
} }
@@ -146,14 +149,24 @@ function expectNoEvent(
async function login(app: TestingApp) { async function login(app: TestingApp) {
const user = await app.createUser(); const user = await app.createUser();
const res = await app const cookieRes = await app
.POST('/api/auth/sign-in') .POST('/api/auth/sign-in')
.send({ email: user.email, password: user.password }) .send({ email: user.email, password: user.password })
.expect(200); .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('; '); const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
return { user, cookieHeader }; return { user, cookieHeader, token: tokenRes.body.token as string };
} }
function createYjsUpdateBase64() { function createYjsUpdateBase64() {
@@ -217,6 +230,52 @@ test.after.always(async () => {
await app.close(); 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 => { test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', async t => {
const { user, cookieHeader } = await login(app); const { user, cookieHeader } = await login(app);
const spaceId = user.id; const spaceId = user.id;
@@ -110,6 +110,10 @@ export class TestingApp extends ApplyType<INestApplication>() {
async initTestingDB() { async initTestingDB() {
await initTestingDB(this); await initTestingDB(this);
this.clearAuth();
}
clearAuth() {
this.sessionCookie = null; this.sessionCookie = null;
this.currentUserCookie = null; this.currentUserCookie = null;
this.csrfCookie = null; this.csrfCookie = null;
+23 -2
View File
@@ -7,6 +7,14 @@ export interface CacheSetOptions {
ttl?: number; 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 { export function isValidCacheTtl(ttl: unknown): ttl is number {
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0; return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
} }
@@ -32,12 +40,15 @@ export class CacheProvider {
value: T, value: T,
opts: CacheSetOptions = {} opts: CacheSetOptions = {}
): Promise<boolean> { ): Promise<boolean> {
if (opts.ttl) { if (isValidCacheTtl(opts.ttl)) {
return this.redis return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl) .set(key, JSON.stringify(value), 'PX', opts.ttl)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
if (opts.ttl !== undefined) {
return false;
}
return this.redis return this.redis
.set(key, JSON.stringify(value)) .set(key, JSON.stringify(value))
@@ -58,12 +69,15 @@ export class CacheProvider {
value: T, value: T,
opts: CacheSetOptions = {} opts: CacheSetOptions = {}
): Promise<boolean> { ): Promise<boolean> {
if (opts.ttl) { if (isValidCacheTtl(opts.ttl)) {
return this.redis return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX') .set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
.then(v => !!v) .then(v => !!v)
.catch(() => false); .catch(() => false);
} }
if (opts.ttl !== undefined) {
return false;
}
return this.redis return this.redis
.set(key, JSON.stringify(value), 'NX') .set(key, JSON.stringify(value), 'NX')
@@ -78,6 +92,13 @@ export class CacheProvider {
.catch(() => false); .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> { async has(key: string): Promise<boolean> {
return this.redis return this.redis
.exists(key) .exists(key)
@@ -7,12 +7,16 @@ import { GqlArgumentsHost } from '@nestjs/graphql';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ClsServiceManager } from 'nestjs-cls'; import { ClsServiceManager } from 'nestjs-cls';
import type { Socket } from 'socket.io'; import type { Socket } from 'socket.io';
import { z } from 'zod';
type RequestResponse = { type RequestResponse = {
req: Request; req: Request;
res?: Response; res?: Response;
}; };
const RequestCookieValueSchema = z.string().min(1);
const RequestHeaderValueSchema = z.string().min(1);
export function getRequestResponseFromHost( export function getRequestResponseFromHost(
host: ArgumentsHost host: ArgumentsHost
): RequestResponse { ): RequestResponse {
@@ -68,9 +72,7 @@ export function getRequestResponseFromContext(
export function parseCookies( export function parseCookies(
req: IncomingMessage & { cookies?: Record<string, string> } req: IncomingMessage & { cookies?: Record<string, string> }
) { ) {
if (req.cookies) { if (req.cookies) return;
return;
}
const cookieStr = req.headers.cookie ?? ''; const cookieStr = req.headers.cookie ?? '';
req.cookies = cookieStr.split(';').reduce( 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 * 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 { import {
Body, Body,
@@ -6,7 +6,6 @@ import {
Get, Get,
Header, Header,
HttpStatus, HttpStatus,
Logger,
Post, Post,
Query, Query,
Req, Req,
@@ -16,27 +15,33 @@ import type { Request, Response } from 'express';
import { import {
ActionForbidden, ActionForbidden,
Config,
CryptoHelper,
EmailTokenNotFound, EmailTokenNotFound,
getRequestCookie,
InvalidAuthState, InvalidAuthState,
InvalidEmail, InvalidEmail,
InvalidEmailToken,
SignUpForbidden,
Throttle, Throttle,
URLHelper,
UseNamedGuard, UseNamedGuard,
WrongSignInCredentials, WrongSignInCredentials,
} from '../../base'; } from '../../base';
import { Models, TokenType } from '../../models'; import { Models } from '../../models';
import { validators } from '../utils/validators'; import { validators } from '../utils/validators';
import { Public } from './guard'; 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 { CurrentUser, Session } from './session';
import { SessionIssuer } from './session-issuer';
interface PreflightResponse { interface PreflightResponse {
registered: boolean; registered: boolean;
hasPassword: boolean; methods: {
password: { available: boolean };
magicLink: { available: boolean };
oauth: { available: boolean; providers: string[] };
passkey: { available: boolean; discoverable: boolean };
};
} }
interface SignInCredential { interface SignInCredential {
@@ -56,17 +61,25 @@ interface OpenAppSignInCredential {
code: string; code: string;
} }
interface NativeSessionExchangeCredential {
code: string;
}
type SignInResponse = CurrentUser & {
exchangeCode?: string;
};
@Throttle('strict') @Throttle('strict')
@Controller('/api/auth') @Controller('/api/auth')
export class AuthController { export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor( constructor(
private readonly url: URLHelper,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly models: Models, private readonly sessionIssuer: SessionIssuer,
private readonly config: Config, private readonly magicLink: MagicLinkAuthService,
private readonly crypto: CryptoHelper private readonly openApp: OpenAppAuthService,
private readonly authMethods: AuthMethodsService,
private readonly sessionExchange: SessionExchangeService,
private readonly models: Models
) { ) {
if (env.dev) { if (env.dev) {
// set DNS servers in dev mode // set DNS servers in dev mode
@@ -89,19 +102,13 @@ export class AuthController {
} }
validators.assertValidEmail(params.email); validators.assertValidEmail(params.email);
const user = await this.models.user.getUserByEmail(params.email); return this.authMethods.loginPreflight(params.email);
}
if (!user) { @UseNamedGuard('version')
return { @Get('/methods')
registered: false, async boundMethods(@CurrentUser() user: CurrentUser) {
hasPassword: false, return this.authMethods.boundMethods(user.id);
};
}
return {
registered: user.registered,
hasPassword: !!user.password,
};
} }
@Public() @Public()
@@ -142,10 +149,17 @@ export class AuthController {
email: string, email: string,
password: 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); const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
res.status(HttpStatus.OK).send(user); 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( async sendMagicLink(
@@ -154,105 +168,10 @@ export class AuthController {
callbackUrl = '/magic-link', callbackUrl = '/magic-link',
clientNonce?: string clientNonce?: string
) { ) {
if (!this.url.isAllowedCallbackUrl(callbackUrl)) { const payload = await this.magicLink.send(email, callbackUrl, clientNonce);
throw new ActionForbidden(); res.status(HttpStatus.OK).send(payload);
}
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,
});
} }
@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') @Post('/sign-out')
async signOut( async signOut(
@Req() req: Request, @Req() req: Request,
@@ -265,14 +184,15 @@ export class AuthController {
return; return;
} }
const csrfCookie = req.cookies?.[AuthService.csrfCookieName] as if (req.authType === 'jwt') {
| string await this.auth.signOut(session.sessionId, session.user.id);
| undefined; res.status(HttpStatus.OK).send({});
return;
}
const csrfCookie = getRequestCookie(req, AuthService.csrfCookieName);
const csrfHeader = req.get('x-affine-csrf-token'); const csrfHeader = req.get('x-affine-csrf-token');
if ( if (!csrfHeader || !csrfCookie || csrfCookie !== csrfHeader) {
csrfHeader && // optional for backward compatibility, drop after 0.25.0 outdated
(!csrfCookie || csrfCookie !== csrfHeader)
) {
throw new ActionForbidden(); throw new ActionForbidden();
} }
@@ -286,17 +206,8 @@ export class AuthController {
@UseNamedGuard('version') @UseNamedGuard('version')
@Post('/open-app/sign-in-code') @Post('/open-app/sign-in-code')
async openAppSignInCode(@CurrentUser() user?: CurrentUser) { async openAppSignInCode(@CurrentUser() user?: CurrentUser) {
if (!user) { if (!user) throw new ActionForbidden();
throw new ActionForbidden(); const code = await this.openApp.createSignInCode(user);
}
// short-lived one-time code for handing off the authenticated session
const code = await this.models.verificationToken.create(
TokenType.OpenAppSignIn,
user.id,
5 * 60
);
return { code }; return { code };
} }
@@ -308,21 +219,21 @@ export class AuthController {
@Res() res: Response, @Res() res: Response,
@Body() credential: OpenAppSignInCredential @Body() credential: OpenAppSignInCredential
) { ) {
if (!credential?.code) { if (!credential?.code) throw new InvalidAuthState();
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( @Public()
TokenType.OpenAppSignIn, @UseNamedGuard('version')
credential.code @Post('/native/exchange')
); async exchangeSession(
@Req() req: Request,
if (!tokenRecord?.credential) { @Body() credential: NativeSessionExchangeCredential
throw new InvalidAuthState(); ) {
} if (!credential?.code) throw new InvalidAuthState();
return await this.sessionExchange.exchange(req, credential.code);
await this.auth.setCookies(req, res, tokenRecord.credential);
res.send({ id: tokenRecord.credential });
} }
@Public() @Public()
@@ -334,42 +245,11 @@ export class AuthController {
@Body() @Body()
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential { email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
) { ) {
if (!otp || !email) { if (!otp || !email) throw new EmailTokenNotFound();
throw new EmailTokenNotFound();
}
validators.assertValidEmail(email); validators.assertValidEmail(email);
const identity = await this.magicLink.verify(email, otp, clientNonce);
const consumed = await this.models.magicLinkOtp.consume( const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
email, res.send({ id: identity.userId, exchangeCode });
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 });
} }
@UseNamedGuard('version') @UseNamedGuard('version')
@@ -377,24 +257,6 @@ export class AuthController {
@Public() @Public()
@Get('/session') @Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) { async currentSessionUser(@CurrentUser() user?: CurrentUser) {
return { return { user };
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),
};
} }
} }
@@ -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, UnsupportedClientVersion,
} from '../../base'; } from '../../base';
import { WEBSOCKET_OPTIONS } from '../../base/websocket'; import { WEBSOCKET_OPTIONS } from '../../base/websocket';
import {
extractTokenFromHeader,
getSessionOptionsFromRequest,
SessionIdSchema,
} from './input';
import { isLikelyJwt, JwtSessionService } from './jwt-session';
import { AuthService } from './service'; import { AuthService } from './service';
import { Session, TokenSession } from './session'; 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_TTL_MS = 5 * 60 * 1000;
const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 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() @Injectable()
export class AuthGuard implements CanActivate, OnModuleInit { export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService; private auth!: AuthService;
private jwtSession!: JwtSessionService;
private readonly cachedVersionRange = new Map<string, semver.Range | null>(); private readonly cachedVersionRange = new Map<string, semver.Range | null>();
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0'; private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)'; private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)';
@@ -48,6 +61,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
onModuleInit() { onModuleInit() {
this.auth = this.ref.get(AuthService, { strict: false }); this.auth = this.ref.get(AuthService, { strict: false });
this.jwtSession = this.ref.get(JwtSessionService, { strict: false });
} }
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
@@ -110,12 +124,102 @@ export class AuthGuard implements CanActivate, OnModuleInit {
res?: Response, res?: Response,
isPublic = false isPublic = false
): Promise<Session | TokenSession | null> { ): Promise<Session | TokenSession | null> {
const userSession = await this.signInWithCookie(req, res, isPublic); const result = await this.resolveRequestSession(req, res, isPublic);
if (userSession) { return result?.type === 'access_token'
return userSession; ? 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( async signInWithCookie(
@@ -123,37 +227,24 @@ export class AuthGuard implements CanActivate, OnModuleInit {
res?: Response, res?: Response,
isPublic = false isPublic = false
): Promise<Session | null> { ): Promise<Session | null> {
if (req.session) { if (req.session) return req.session;
return req.session;
}
// TODO(@forehalo): a cache for user session // TODO(@forehalo): a cache for user session
const userSession = await this.auth.getUserSessionFromRequest(req, res); const userSession = await this.auth.getUserSessionFromRequest(req, res);
if (userSession) { if (userSession) {
const headerClientVersion = getClientVersionFromRequest(req); const headerClientVersion = getClientVersionFromRequest(req);
if (this.config.client.versionControl.enabled) { req.session = { ...userSession.session, user: userSession.user };
const clientVersion =
headerClientVersion ??
userSession.session.refreshClientVersion ??
userSession.session.signInClientVersion;
const versionCheckResult = this.checkClientVersion(clientVersion); const versionAllowed = await this.checkUserSessionClientVersion(
if (!versionCheckResult.ok) { req,
await this.auth.signOut(userSession.session.sessionId); req.session,
if (res) { res,
await this.auth.refreshCookies(res, userSession.session.sessionId); isPublic
} );
if (!versionAllowed) {
if (isPublic) { req.session = undefined;
return null; return null;
}
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: versionCheckResult.requiredVersion,
});
}
} }
if (res) { if (res) {
@@ -165,10 +256,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
); );
} }
req.session = { req.authType = 'session';
...userSession.session,
user: userSession.user,
};
return req.session; return req.session;
} }
@@ -176,6 +264,42 @@ export class AuthGuard implements CanActivate, OnModuleInit {
return null; 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> { async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
if (req.token) { if (req.token) {
return req.token; return req.token;
@@ -184,10 +308,8 @@ export class AuthGuard implements CanActivate, OnModuleInit {
const tokenSession = await this.auth.getTokenSessionFromRequest(req); const tokenSession = await this.auth.getTokenSessionFromRequest(req);
if (tokenSession) { if (tokenSession) {
req.token = { req.token = { ...tokenSession.token, user: tokenSession.user };
...tokenSession.token, req.authType = 'access_token';
user: tokenSession.user,
};
return req.token; return req.token;
} }
@@ -280,11 +402,9 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = {
// compatibility with websocket request // compatibility with websocket request
parseCookies(upgradeReq); parseCookies(upgradeReq);
upgradeReq.cookies = { if (handshake.auth.tokenType === 'jwt') {
[AuthService.sessionCookieName]: handshake.auth.token, upgradeReq.headers.authorization = `Bearer ${handshake.auth.token}`;
[AuthService.userCookieName]: handshake.auth.userId, }
...upgradeReq.cookies,
};
const session = await (async () => { const session = await (async () => {
try { 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 { MailModule } from '../mail';
import { QuotaModule } from '../quota'; import { QuotaModule } from '../quota';
import { UserModule } from '../user'; import { UserModule } from '../user';
import { AuthChallengeStore } from './challenge-store';
import { AuthController } from './controller'; import { AuthController } from './controller';
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard'; import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthCronJob } from './job'; 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 { AuthResolver } from './resolver';
import { AuthService } from './service'; import { AuthService } from './service';
import { SessionIssuer } from './session-issuer';
@Module({ @Module({
imports: [FeatureModule, UserModule, QuotaModule, MailModule], imports: [FeatureModule, UserModule, QuotaModule, MailModule],
@@ -18,15 +25,40 @@ import { AuthService } from './service';
AuthService, AuthService,
AuthResolver, AuthResolver,
AuthGuard, AuthGuard,
JwtSessionService,
SessionIssuer,
AuthChallengeStore,
MagicLinkAuthService,
OpenAppAuthService,
AuthMethodsService,
SessionExchangeService,
AuthCronJob, AuthCronJob,
AuthWebsocketOptionsProvider, AuthWebsocketOptionsProvider,
], ],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider], exports: [
AuthService,
AuthGuard,
JwtSessionService,
SessionIssuer,
AuthChallengeStore,
MagicLinkAuthService,
OpenAppAuthService,
AuthMethodsService,
SessionExchangeService,
AuthWebsocketOptionsProvider,
],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}
export { AuthChallengeStore } from './challenge-store';
export * from './guard'; 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 { ClientTokenType } from './resolver';
export { AuthService }; export { AuthService, JwtSessionService, SessionIssuer };
export * from './session'; 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, { @ResolveField(() => ClientTokenType, {
name: 'token', name: 'token',
deprecationReason: 'use [/api/auth/sign-in?native=true] instead', deprecationReason: 'use native session exchange instead',
}) })
async clientToken( async clientToken(
@CurrentUser() currentUser: CurrentUser, @CurrentUser() currentUser: CurrentUser,
@@ -4,14 +4,18 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express'; import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es'; import { assign, pick } from 'lodash-es';
import { import { Config, SignUpForbidden } from '../../base';
Config,
getClientVersionFromRequest,
SignUpForbidden,
} from '../../base';
import { Models, type User, type UserSession } from '../../models'; import { Models, type User, type UserSession } from '../../models';
import { Mailer } from '../mail/mailer'; import { Mailer } from '../mail/mailer';
import { createDevUsers } from './dev'; 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'; import type { CurrentUser } from './session';
export function sessionUser( 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() @Injectable()
export class AuthService implements OnApplicationBootstrap { export class AuthService implements OnApplicationBootstrap {
readonly cookieOptions: CookieOptions; readonly cookieOptions: CookieOptions;
static readonly sessionCookieName = 'affine_session'; static readonly sessionCookieName = SESSION_COOKIE_NAME;
static readonly userCookieName = 'affine_user_id'; static readonly userCookieName = USER_COOKIE_NAME;
static readonly csrfCookieName = 'affine_csrf_token'; static readonly csrfCookieName = CSRF_COOKIE_NAME;
constructor( constructor(
private readonly config: Config, private readonly config: Config,
@@ -90,6 +86,14 @@ export class AuthService implements OnApplicationBootstrap {
return this.models.user.signIn(email, password).then(sessionUser); 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) { async signOut(sessionId: string, userId?: string) {
// sign out all users in the session // sign out all users in the session
if (!userId) { if (!userId) {
@@ -104,10 +108,7 @@ export class AuthService implements OnApplicationBootstrap {
userId?: string userId?: string
): Promise<{ user: CurrentUser; session: UserSession } | null> { ): Promise<{ user: CurrentUser; session: UserSession } | null> {
const sessions = await this.getUserSessions(sessionId); const sessions = await this.getUserSessions(sessionId);
if (!sessions.length) return null;
if (!sessions.length) {
return null;
}
let userSession: UserSession | undefined; let userSession: UserSession | undefined;
@@ -201,55 +202,6 @@ export class AuthService implements OnApplicationBootstrap {
return await this.models.session.deleteUserSessions(userId); 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) { async refreshCookies(res: Response, sessionId?: string) {
if (sessionId) { if (sessionId) {
const users = await this.getUserList(sessionId); const users = await this.getUserList(sessionId);
@@ -264,7 +216,7 @@ export class AuthService implements OnApplicationBootstrap {
this.clearCookies(res); 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.sessionCookieName);
res.clearCookie(AuthService.userCookieName); res.clearCookie(AuthService.userCookieName);
res.clearCookie(AuthService.csrfCookieName); res.clearCookie(AuthService.csrfCookieName);
@@ -281,12 +233,8 @@ export class AuthService implements OnApplicationBootstrap {
} }
async getUserSessionFromRequest(req: Request, res?: Response) { async getUserSessionFromRequest(req: Request, res?: Response) {
const { sessionId, userId } = this.getSessionOptionsFromRequest(req); const { sessionId, userId } = getSessionOptionsFromRequest(req);
if (!sessionId) return null;
if (!sessionId) {
return null;
}
const session = await this.getUserSession(sessionId, userId); const session = await this.getUserSession(sessionId, userId);
if (res) { 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, UseNamedGuard,
} from '../../base'; } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import { AuthService, Public } from '../auth'; import { Public, SessionIssuer } from '../auth';
import { ServerService } from '../config'; import { ServerService } from '../config';
import { validators } from '../utils/validators'; import { validators } from '../utils/validators';
@@ -26,7 +26,7 @@ export class CustomSetupController {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly models: Models, private readonly models: Models,
private readonly auth: AuthService, private readonly sessionIssuer: SessionIssuer,
private readonly mutex: Mutex, private readonly mutex: Mutex,
private readonly server: ServerService private readonly server: ServerService
) {} ) {}
@@ -72,7 +72,10 @@ export class CustomSetupController {
'selfhost setup' '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 }); res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) { } catch (e) {
await this.models.user.delete(user.id); await this.models.user.delete(user.id);
+1
View File
@@ -2,6 +2,7 @@ declare namespace Express {
interface Request { interface Request {
session?: import('./core/auth/session').Session; session?: import('./core/auth/session').Session;
token?: import('./core/auth/session').TokenSession; token?: import('./core/auth/session').TokenSession;
authType?: 'jwt' | 'session' | 'access_token';
} }
} }
@@ -13,8 +13,6 @@ export enum TokenType {
VerifyEmail, VerifyEmail,
ChangeEmail, ChangeEmail,
ChangePassword, ChangePassword,
Challenge,
OpenAppSignIn,
} }
@Injectable() @Injectable()
@@ -12,7 +12,7 @@ import {
OnEvent, OnEvent,
} from '../../base'; } from '../../base';
import { ServerFeature, ServerService } from '../../core'; import { ServerFeature, ServerService } from '../../core';
import { Models, TokenType } from '../../models'; import { AuthChallengeStore } from '../../core/auth';
import { verifyChallengeResponse } from '../../native'; import { verifyChallengeResponse } from '../../native';
import { CaptchaConfig } from './types'; import { CaptchaConfig } from './types';
@@ -28,7 +28,7 @@ export class CaptchaService {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly models: Models, private readonly challenges: AuthChallengeStore,
private readonly server: ServerService private readonly server: ServerService
) { ) {
this.captcha = config.captcha.config; this.captcha = config.captcha.config;
@@ -93,10 +93,10 @@ export class CaptchaService {
async getChallengeToken() { async getChallengeToken() {
const resource = randomUUID(); const resource = randomUUID();
const challenge = await this.models.verificationToken.create( const challenge = await this.challenges.create(
TokenType.Challenge, 'captcha',
resource, resource,
5 * 60 5 * 60 * 1000
); );
return { return {
@@ -117,9 +117,7 @@ export class CaptchaService {
const challenge = credential.challenge; const challenge = credential.challenge;
let resource: string | null = null; let resource: string | null = null;
if (typeof challenge === 'string' && challenge) { if (typeof challenge === 'string' && challenge) {
resource = await this.models.verificationToken resource = await this.challenges.consume<string>('captcha', challenge);
.get(TokenType.Challenge, challenge)
.then(token => token?.credential || null);
} }
if (resource) { if (resource) {
@@ -3,68 +3,63 @@ import {
Controller, Controller,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger,
Post, Post,
type RawBodyRequest, type RawBodyRequest,
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConnectedAccount } from '@prisma/client';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { import {
ActionForbidden, ActionForbidden,
Config,
getClientVersionFromRequest, getClientVersionFromRequest,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter, MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider, UnknownOauthProvider,
URLHelper, URLHelper,
UseNamedGuard, UseNamedGuard,
} from '../../base'; } from '../../base';
import { AuthService, Public } from '../../core/auth'; import {
import { Models } from '../../models'; OAuthCallbackBodySchema,
OAuthPreflightBodySchema,
Public,
SessionIssuer,
} from '../../core/auth';
import { OAuthProviderName } from './config'; import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory'; import { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthService } from './service'; import { OAuthService } from './service';
@Controller('/api/oauth') @Controller('/api/oauth')
export class OAuthController { export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor( constructor(
private readonly auth: AuthService, private readonly sessionIssuer: SessionIssuer,
private readonly oauth: OAuthService, private readonly oauth: OAuthService,
private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory, private readonly providerFactory: OAuthProviderFactory,
private readonly url: URLHelper, private readonly url: URLHelper
private readonly config: Config
) {} ) {}
@Public() @Public()
@UseNamedGuard('version') @UseNamedGuard('version')
@Post('/preflight') @Post('/preflight')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async preflight( async preflight(@Req() req: Request, @Body() body?: unknown) {
@Req() req: Request, const input = OAuthPreflightBodySchema.safeParse(body);
@Body('provider') unknownProviderName?: keyof typeof OAuthProviderName, if (!input.success) {
@Body('redirect_uri') redirectUri?: string, const fields = new Set(input.error.issues.map(issue => issue.path[0]));
@Body('client') client?: string, if (fields.has('client_nonce')) {
@Body('client_nonce') clientNonce?: string throw new MissingOauthQueryParameter({ name: 'client_nonce' });
) { }
if (!unknownProviderName) {
throw new MissingOauthQueryParameter({ name: 'provider' }); 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); const provider = this.providerFactory.get(providerName);
if (!provider) { if (!provider) {
@@ -123,57 +118,38 @@ export class OAuthController {
async callback( async callback(
@Req() req: RawBodyRequest<Request>, @Req() req: RawBodyRequest<Request>,
@Res() res: Response, @Res() res: Response,
@Body('code') code?: string, @Body() body?: unknown
@Body('state') stateStr?: string,
@Body('client_nonce') clientNonce?: string
) { ) {
// TODO(@forehalo): refactor and remove deprecated code in 0.23 const input = OAuthCallbackBodySchema.safeParse(body);
if (!code) { if (!input.success) {
throw new MissingOauthQueryParameter({ name: 'code' }); const fields = new Set(input.error.issues.map(issue => issue.path[0]));
} if (fields.has('code')) {
throw new MissingOauthQueryParameter({ name: 'code' });
if (!stateStr) { }
if (fields.has('state')) {
throw new MissingOauthQueryParameter({ name: '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 const { code, state: stateStr, client_nonce: clientNonce } = input.data;
let rawState = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
rawState = JSON.parse(stateStr);
stateStr = rawState.state;
} catch {
/* noop */
}
}
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) { const verified = await this.oauth.verifyCallback({
throw new InvalidOauthCallbackState(); code,
} stateStr,
clientNonce,
rawBody: req.rawBody,
});
const state = await this.oauth.getOAuthState(stateStr); if (verified.type === 'handoff') {
const clientUrl = new URL(`${verified.state.client}://authentication`);
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`);
clientUrl.searchParams.set('method', 'oauth'); clientUrl.searchParams.set('method', 'oauth');
clientUrl.searchParams.set( clientUrl.searchParams.set(
'payload', 'payload',
JSON.stringify({ JSON.stringify({
state: stateStr, state: verified.stateToken,
code, code,
provider: rawState.provider, provider: verified.provider,
}) })
); );
clientUrl.searchParams.set('server', this.url.requestOrigin); clientUrl.searchParams.set('server', this.url.requestOrigin);
@@ -185,46 +161,8 @@ export class OAuthController {
); );
} }
if (!state.provider) { const { identity, state } = verified;
throw new MissingOauthQueryParameter({ name: 'provider' }); const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
}
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);
if ( if (
state.provider === OAuthProviderName.Apple && state.provider === OAuthProviderName.Apple &&
@@ -234,96 +172,9 @@ export class OAuthController {
} }
res.send({ res.send({
id: user.id, id: identity.userId,
exchangeCode,
redirectUri: state.redirectUri, 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 { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core'; import { ServerConfigModule } from '../../core';
import { AuthModule } from '../../core/auth'; import { AUTH_OAUTH_PROVIDER_READER, AuthModule } from '../../core/auth';
import { UserModule } from '../../core/user'; import { UserModule } from '../../core/user';
import { OAuthController } from './controller'; import { OAuthController } from './controller';
import { OAuthProviderFactory } from './factory'; import { OAuthProviderFactory } from './factory';
@@ -15,6 +15,7 @@ import { OAuthService } from './service';
imports: [AuthModule, UserModule, ServerConfigModule], imports: [AuthModule, UserModule, ServerConfigModule],
providers: [ providers: [
OAuthProviderFactory, OAuthProviderFactory,
{ provide: AUTH_OAUTH_PROVIDER_READER, useExisting: OAuthProviderFactory },
OAuthService, OAuthService,
OAuthResolver, OAuthResolver,
...OAuthProviders, ...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 { OAuthProviderFactory } from './factory';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthPkceChallenge, OAuthState } from './types'; 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() @Injectable()
export class OAuthService { export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor( constructor(
private readonly providerFactory: OAuthProviderFactory, 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) { isValidState(stateStr: string) {
@@ -20,23 +59,191 @@ export class OAuthService {
} }
async saveOAuthState(state: OAuthState) { async saveOAuthState(state: OAuthState) {
const token = randomUUID(); return this.challenges.create<OAuthState>(
const payload: OAuthState = { ...state, token }; 'oauth_state',
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, { token => ({ ...state, token }),
ttl: 3600 * 3 * 1000 /* 3 hours */, OAUTH_STATE_TTL_MS
}); );
return token;
} }
async getOAuthState(token: string) { async getOAuthState(token: string) {
return this.cache.get<OAuthState>(`${OAUTH_STATE_KEY}:${token}`); return this.challenges.get<OAuthState>('oauth_state', token);
} }
availableOAuthProviders() { availableOAuthProviders() {
return this.providerFactory.providers; 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 { createPkcePair(): OAuthPkceChallenge {
const codeVerifier = this.randomBase64Url(96); const codeVerifier = this.randomBase64Url(96);
const hash = createHash('sha256').update(codeVerifier).digest(); const hash = createHash('sha256').update(codeVerifier).digest();
+1 -1
View File
@@ -2679,7 +2679,7 @@ type UserType {
"""Get user settings""" """Get user settings"""
settings: UserSettingsType! settings: UserSettingsType!
subscriptions: [SubscriptionType!]! 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 { type ValidationErrorDataType {
+1 -1
View File
@@ -1887,7 +1887,7 @@ export const getCurrentUserQuery = {
} }
} }
}`, }`,
deprecations: ["'token' is deprecated: use [/api/auth/sign-in?native=true] instead"], deprecations: ["'token' is deprecated: use native session exchange instead"],
}; };
export const getDocCreatedByUpdatedByListQuery = { export const getDocCreatedByUpdatedByListQuery = {
+1 -1
View File
@@ -3409,7 +3409,7 @@ export interface UserType {
/** Get user settings */ /** Get user settings */
settings: UserSettingsType; settings: UserSettingsType;
subscriptions: Array<SubscriptionType>; subscriptions: Array<SubscriptionType>;
/** @deprecated use [/api/auth/sign-in?native=true] instead */ /** @deprecated use native session exchange instead */
token: TokenType; token: TokenType;
} }
@@ -1,9 +1,6 @@
package app.affine.pro package app.affine.pro
import android.webkit.WebView import android.webkit.WebView
import app.affine.pro.service.CookieStore
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.get
import app.affine.pro.utils.getCurrentServerBaseUrl import app.affine.pro.utils.getCurrentServerBaseUrl
import app.affine.pro.utils.logger.FileTree import app.affine.pro.utils.logger.FileTree
import com.getcapacitor.Bridge import com.getcapacitor.Bridge
@@ -11,7 +8,6 @@ import com.getcapacitor.WebViewListener
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
@@ -23,30 +19,11 @@ object AuthInitializer {
bridge.removeWebViewListener(this) bridge.removeWebViewListener(this)
MainScope().launch(Dispatchers.IO) { MainScope().launch(Dispatchers.IO) {
try { try {
val server = bridge.getCurrentServerBaseUrl().toHttpUrl() FileTree.get()?.checkAndUploadOldLogs(
val sessionCookieStr = AFFiNEApp.context().dataStore bridge.getCurrentServerBaseUrl().toHttpUrl()
.get(server.host + CookieStore.AFFINE_SESSION)
val userIdCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_USER_ID)
val csrfCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_CSRF_TOKEN)
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty() || csrfCookieStr.isEmpty()) {
Timber.i("[init] user has not signed in yet.")
return@launch
}
Timber.i("[init] user already signed in.")
val cookies = listOf(
Cookie.parse(server, sessionCookieStr)
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
Cookie.parse(server, userIdCookieStr)
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
Cookie.parse(server, csrfCookieStr)
?: error("Parse csrf token cookie fail:[ cookie = $csrfCookieStr ]"),
) )
CookieStore.saveCookies(server.host, cookies)
FileTree.get()?.checkAndUploadOldLogs(server)
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.") Timber.w(e, "[init] auth initializer fail.")
} }
} }
} }
@@ -1,8 +1,16 @@
package app.affine.pro.plugin package app.affine.pro.plugin
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import app.affine.pro.AFFiNEApp
import app.affine.pro.service.AuthHttp
import app.affine.pro.service.CookieStore import app.affine.pro.service.CookieStore
import app.affine.pro.service.OkHttp import app.affine.pro.utils.dataStore
import app.affine.pro.utils.del
import app.affine.pro.utils.get
import app.affine.pro.utils.set
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
@@ -10,6 +18,7 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
@@ -17,10 +26,90 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync import okhttp3.coroutines.executeAsync
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@CapacitorPlugin(name = "Auth") @CapacitorPlugin(name = "Auth")
class AuthPlugin : Plugin() { class AuthPlugin : Plugin() {
private fun canonicalEndpoint(endpoint: String): String = try {
val url = endpoint.toHttpUrl()
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
"${url.scheme}://${url.host}$port"
} catch (_: Exception) {
endpoint
}
private fun tokenKey(endpoint: String) = "auth-token:${canonicalEndpoint(endpoint)}"
private fun legacyTokenKey(endpoint: String) = "auth-token:$endpoint"
private val tokenCipher = TokenCipher()
@PluginMethod
fun readEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val key = tokenKey(endpoint)
val legacyKey = legacyTokenKey(endpoint)
val store = AFFiNEApp.context().dataStore
val storedKey = key.takeIf { store.get(it).isNotEmpty() }
?: legacyKey.takeIf { it != key && store.get(it).isNotEmpty() }
val storedToken = storedKey?.let { store.get(it) }?.takeIf { it.isNotEmpty() }
val token = storedToken?.let {
tokenCipher.decrypt(it) ?: tokenCipher.legacyPlaintext(it)
}
if (
storedToken != null &&
token != null &&
(storedKey != key || !tokenCipher.isEncrypted(storedToken))
) {
store.set(key, tokenCipher.encrypt(token))
storedKey?.let {
if (it != key) {
store.del(it)
}
}
}
call.resolve(JSObject().put("token", token))
} catch (e: Exception) {
call.reject("Failed to read endpoint token.", null, e)
}
}
}
@PluginMethod
fun writeEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val token = call.getStringEnsure("token")
AFFiNEApp.context().dataStore.set(
tokenKey(endpoint),
tokenCipher.encrypt(token)
)
call.resolve(JSObject().put("ok", true))
} catch (e: Exception) {
call.reject("Failed to write endpoint token.", null, e)
}
}
}
@PluginMethod
fun deleteEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
AFFiNEApp.context().dataStore.del(tokenKey(endpoint))
AFFiNEApp.context().dataStore.del(legacyTokenKey(endpoint))
call.resolve(JSObject().put("ok", true))
} catch (e: Exception) {
call.reject("Failed to delete endpoint token.", null, e)
}
}
}
@PluginMethod @PluginMethod
fun signInMagicLink(call: PluginCall) { fun signInMagicLink(call: PluginCall) {
@@ -32,6 +121,11 @@ class AuthPlugin : Plugin() {
processSignIn(call, SignInMethod.Oauth) processSignIn(call, SignInMethod.Oauth)
} }
@PluginMethod
fun signInOpenApp(call: PluginCall) {
processSignIn(call, SignInMethod.OpenApp)
}
@SuppressLint("BuildListAdds") @SuppressLint("BuildListAdds")
@PluginMethod @PluginMethod
fun signInPassword(call: PluginCall) { fun signInPassword(call: PluginCall) {
@@ -43,21 +137,22 @@ class AuthPlugin : Plugin() {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
try { try {
val endpoint = call.getStringEnsure("endpoint") val endpoint = call.getStringEnsure("endpoint")
val csrfToken = CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_CSRF_TOKEN) val token = call.getString("token")
val request = Request.Builder() val request = Request.Builder()
.url("$endpoint/api/auth/sign-out") .url("$endpoint/api/auth/sign-out")
.post("".toRequestBody("application/json".toMediaTypeOrNull())) .post("".toRequestBody("application/json".toMediaTypeOrNull()))
.apply { .apply {
if (csrfToken != null) { if (token != null) {
addHeader("x-affine-csrf-token", csrfToken) addHeader("Authorization", "Bearer $token")
} }
} }
.build() .build()
OkHttp.client.newCall(request).executeAsync().use { response -> AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) { if (response.code >= 400) {
call.reject(response.body.string()) call.reject(response.body.string())
return@launch return@launch
} }
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
Timber.i("Sign out success.") Timber.i("Sign out success.")
call.resolve(JSObject().put("ok", true)) call.resolve(JSObject().put("ok", true))
} }
@@ -69,7 +164,7 @@ class AuthPlugin : Plugin() {
} }
private enum class SignInMethod { private enum class SignInMethod {
Password, Oauth, MagicLink Password, Oauth, MagicLink, OpenApp
} }
private fun processSignIn(call: PluginCall, method: SignInMethod) { private fun processSignIn(call: PluginCall, method: SignInMethod) {
@@ -92,6 +187,7 @@ class AuthPlugin : Plugin() {
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
.url("$endpoint/api/auth/sign-in") .url("$endpoint/api/auth/sign-in")
.addHeader("x-affine-client-kind", "native")
.post(body) .post(body)
if (verifyToken != null) { if (verifyToken != null) {
requestBuilder.addHeader("x-captcha-token", verifyToken) requestBuilder.addHeader("x-captcha-token", verifyToken)
@@ -117,6 +213,7 @@ class AuthPlugin : Plugin() {
Request.Builder() Request.Builder()
.url("$endpoint/api/oauth/callback") .url("$endpoint/api/oauth/callback")
.addHeader("x-affine-client-kind", "native")
.post(body) .post(body)
.build() .build()
} }
@@ -136,19 +233,41 @@ class AuthPlugin : Plugin() {
Request.Builder() Request.Builder()
.url("$endpoint/api/auth/magic-link") .url("$endpoint/api/auth/magic-link")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
}
SignInMethod.OpenApp -> {
val code = call.getStringEnsure("code")
val body = JSONObject()
.apply { put("code", code) }
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/auth/open-app/sign-in")
.addHeader("x-affine-client-kind", "native")
.post(body) .post(body)
.build() .build()
} }
} }
OkHttp.client.newCall(request).executeAsync().use { response -> AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) { if (response.code >= 400) {
call.reject(response.body.string()) call.reject(response.body.string())
return@launch return@launch
} }
CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_SESSION)?.let { val exchangeCode = JSONObject(response.body.string()).optString("exchangeCode").takeIf { it.isNotEmpty() }
if (exchangeCode == null) {
Timber.w("$method sign in fail, exchange code not found.")
call.reject("$method sign in fail, exchange code not found")
return@launch
}
val token = exchangeSession(endpoint, exchangeCode)
token.takeIf { it.isNotEmpty() }?.let {
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
Timber.i("$method sign in success.") Timber.i("$method sign in success.")
Timber.d("Update session [$it]")
call.resolve(JSObject().put("token", it)) call.resolve(JSObject().put("token", it))
} ?: run { } ?: run {
Timber.w("$method sign in fail, token not found.") Timber.w("$method sign in fail, token not found.")
@@ -161,4 +280,88 @@ class AuthPlugin : Plugin() {
} }
} }
} }
private suspend fun exchangeSession(endpoint: String, code: String): String {
val body = JSONObject()
.apply { put("code", code) }
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url("$endpoint/api/auth/native/exchange")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
throw Exception(response.body.string())
}
return JSONObject(response.body.string()).optString("token")
}
}
}
private class TokenCipher {
private val alias = "affine-native-auth-token"
private val transformation = "AES/GCM/NoPadding"
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return listOf(
"v1",
Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
Base64.encodeToString(ciphertext, Base64.NO_WRAP),
).joinToString(":")
}
fun decrypt(encoded: String): String? {
val parts = encoded.split(":")
if (parts.size != 3 || parts[0] != "v1") {
return null
}
return try {
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation)
cipher.init(
Cipher.DECRYPT_MODE,
secretKey(),
GCMParameterSpec(128, iv)
)
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} catch (e: Exception) {
Timber.w(e, "Failed to decrypt auth token.")
null
}
}
fun isEncrypted(value: String) = value.startsWith("v1:")
fun legacyPlaintext(value: String) =
value.takeIf { !isEncrypted(it) && it.isNotBlank() }
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let {
return it.secretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
} }
@@ -3,6 +3,7 @@ package app.affine.pro.service
import app.affine.pro.AFFiNEApp import app.affine.pro.AFFiNEApp
import app.affine.pro.CapacitorConfig import app.affine.pro.CapacitorConfig
import app.affine.pro.utils.dataStore import app.affine.pro.utils.dataStore
import app.affine.pro.utils.del
import app.affine.pro.utils.set import app.affine.pro.utils.set
import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase import com.google.firebase.ktx.Firebase
@@ -50,6 +51,20 @@ object OkHttp {
} }
object AuthHttp {
val client = OkHttpClient.Builder()
.cookieJar(CookieJar.NO_COOKIES)
.addInterceptor {
it.proceed(
it.request()
.newBuilder()
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
.build()
)
}
.build()
}
object CookieStore { object CookieStore {
const val AFFINE_SESSION = "affine_session" const val AFFINE_SESSION = "affine_session"
@@ -61,9 +76,6 @@ object CookieStore {
fun saveCookies(host: String, cookies: List<Cookie>) { fun saveCookies(host: String, cookies: List<Cookie>) {
_cookies[host] = cookies _cookies[host] = cookies
MainScope().launch(Dispatchers.IO) { MainScope().launch(Dispatchers.IO) {
cookies.find { it.name == AFFINE_SESSION }?.let {
AFFiNEApp.context().dataStore.set(host + AFFINE_SESSION, it.toString())
}
cookies.find { it.name == AFFINE_USER_ID }?.let { cookies.find { it.name == AFFINE_USER_ID }?.let {
Timber.d("Update user id [${it.value}]") Timber.d("Update user id [${it.value}]")
AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString()) AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
@@ -77,6 +89,18 @@ object CookieStore {
fun getCookies(host: String) = _cookies[host] ?: emptyList() fun getCookies(host: String) = _cookies[host] ?: emptyList()
fun clearAuthCookies(host: String) {
val cookies = _cookies[host] ?: emptyList()
_cookies[host] = cookies.filter {
it.name != AFFINE_SESSION && it.name != AFFINE_USER_ID && it.name != AFFINE_CSRF_TOKEN
}
MainScope().launch(Dispatchers.IO) {
AFFiNEApp.context().dataStore.del(host + AFFINE_USER_ID)
AFFiNEApp.context().dataStore.del(host + AFFINE_CSRF_TOKEN)
Firebase.crashlytics.setUserId("")
}
}
fun getCookie(url: HttpUrl, name: String) = url.host fun getCookie(url: HttpUrl, name: String) = url.host
.let { _cookies[it] } .let { _cookies[it] }
?.find { cookie -> cookie.name == name } ?.find { cookie -> cookie.name == name }
@@ -17,6 +17,12 @@ suspend fun DataStore<Preferences>.set(key: String, value: String) {
} }
} }
suspend fun DataStore<Preferences>.del(key: String) {
edit {
it.remove(stringPreferencesKey(key))
}
}
suspend fun DataStore<Preferences>.get(key: String) = data.map { suspend fun DataStore<Preferences>.get(key: String) = data.map {
it[stringPreferencesKey(key)] ?: "" it[stringPreferencesKey(key)] ?: ""
}.first() }.first()
+32 -3
View File
@@ -57,7 +57,11 @@ import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash'; import { HashCash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore'; import { NbStoreNativeDBApis } from './plugins/nbstore';
import { Preview } from './plugins/preview'; import { Preview } from './plugins/preview';
import { writeEndpointToken } from './proxy'; import {
deleteEndpointToken,
readEndpointToken,
writeEndpointToken,
} from './proxy';
const storeManagerClient = createStoreManagerClient(); const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry); setTelemetryTransport(storeManagerClient.telemetry);
@@ -206,10 +210,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
}); });
await writeEndpointToken(endpoint, token); await writeEndpointToken(endpoint, token);
}, },
async signOut() { async signInOpenAppSignInCode(code) {
await Auth.signOut({ const { token } = await Auth.signInOpenApp({
endpoint, endpoint,
code,
}); });
await writeEndpointToken(endpoint, token);
},
async signOut() {
const token = await readEndpointToken(endpoint);
try {
await Auth.signOut({ endpoint, token });
} finally {
await deleteEndpointToken(endpoint);
}
}, },
}; };
}); });
@@ -442,5 +456,20 @@ function createStoreManagerClient() {
}, },
[nativeDBApiChannelClient] [nativeDBApiChannelClient]
); );
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
new MessageChannel();
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
readEndpointToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
});
authTokenChannelServer.start();
worker.postMessage(
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
);
return new StoreManagerClient(new OpClient(worker)); return new StoreManagerClient(new OpClient(worker));
} }
@@ -18,19 +18,28 @@ import {
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc';
import { readEndpointToken } from './proxy'; let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
configureSocketAuthMethod((endpoint, cb) => { configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint) readEndpointToken(endpoint)
.then(token => { .then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
cb({ token }); .catch(() => cb({}));
})
.catch(e => {
console.error(e);
});
}); });
globalThis.addEventListener('message', e => { globalThis.addEventListener('message', e => {
if (e.data.type === 'native-auth-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token } = e.data as { id?: string; token?: string | null };
if (!id) return;
pendingTokenRequests.get(id)?.(token ?? null);
pendingTokenRequests.delete(id);
});
authTokenPort.start();
return;
}
if (e.data.type === 'native-db-api-channel') { if (e.data.type === 'native-db-api-channel') {
const port = e.ports[0] as MessagePort; const port = e.ports[0] as MessagePort;
const rpc = AsyncCall<NativeDBApis>( const rpc = AsyncCall<NativeDBApis>(
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
} }
}); });
function readEndpointToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>(resolve => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
resolve(null);
}, 5000);
pendingTokenRequests.set(id, token => {
clearTimeout(timeout);
resolve(token);
});
authTokenPort?.postMessage({ id, endpoint });
});
}
const consumer = new OpConsumer<WorkerManagerOps>( const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable globalThis as MessageCommunicapable
); );
@@ -18,5 +18,17 @@ export interface AuthPlugin {
verifyToken?: string; verifyToken?: string;
challenge?: string; challenge?: string;
}): Promise<{ token: string }>; }): Promise<{ token: string }>;
signOut(options: { endpoint: string }): Promise<void>; signInOpenApp(options: {
endpoint: string;
code: string;
}): Promise<{ token: string }>;
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
readEndpointToken(options: {
endpoint: string;
}): Promise<{ token?: string | null }>;
writeEndpointToken(options: {
endpoint: string;
token: string;
}): Promise<void>;
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
} }
+53 -24
View File
@@ -1,4 +1,19 @@
import { openDB } from 'idb'; import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(url, globalThis.location.origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
function canonicalEndpoint(endpoint: string) {
return authEndpointForUrl(endpoint) ?? endpoint;
}
/** /**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview. * the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init); const request = new Request(input, init);
const origin = new URL(request.url, globalThis.location.origin).origin; const origin = authEndpointForUrl(request.url);
const token = await readEndpointToken(origin); const token = origin
? await readEndpointToken(origin).catch(() => null)
: null;
if (token) { if (token) {
request.headers.set('Authorization', `Bearer ${token}`); request.headers.set('Authorization', `Bearer ${token}`);
} }
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
}; };
const rawXMLHttpRequest = globalThis.XMLHttpRequest; const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
override send(body?: Document | XMLHttpRequestBodyInit | null): void { override open(
const origin = new URL(this.responseURL, globalThis.location.origin).origin; method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
readEndpointToken(origin).then( override send(body?: Document | XMLHttpRequestBodyInit | null): void {
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
token => { token => {
if (token) { if (token) {
this.setRequestHeader('Authorization', `Bearer ${token}`); this.setRequestHeader('Authorization', `Bearer ${token}`);
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
return super.send(body); return super.send(body);
}, },
() => { () => {
throw new Error('Failed to read token'); return super.send(body);
} }
); );
} }
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
export async function readEndpointToken( export async function readEndpointToken(
endpoint: string endpoint: string
): Promise<string | null> { ): Promise<string | null> {
const idb = await openDB('affine-token', 1, { const { token } = await Auth.readEndpointToken({
upgrade(db) { endpoint: canonicalEndpoint(endpoint),
if (!db.objectStoreNames.contains('tokens')) {
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
}); });
return token ?? null;
const token = await idb.get('tokens', endpoint);
return token ? token.token : null;
} }
export async function writeEndpointToken(endpoint: string, token: string) { export async function writeEndpointToken(endpoint: string, token: string) {
const db = await openDB('affine-token', 1, { await Auth.writeEndpointToken({
upgrade(db) { endpoint: canonicalEndpoint(endpoint),
if (!db.objectStoreNames.contains('tokens')) { token,
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
}); });
}
await db.put('tokens', { endpoint, token });
export async function deleteEndpointToken(endpoint: string) {
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
} }
@@ -2,7 +2,12 @@ import { configureElectronStateStorageImpls } from '@affine/core/desktop/storage
import { configureCommonModules } from '@affine/core/modules'; import { configureCommonModules } from '@affine/core/modules';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
import { configureDesktopBackupModule } from '@affine/core/modules/backup'; import { configureDesktopBackupModule } from '@affine/core/modules/backup';
import { ValidatorProvider } from '@affine/core/modules/cloud'; import {
AuthProvider,
ServerScope,
ServerService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { import {
configureDesktopApiModule, configureDesktopApiModule,
DesktopApiService, DesktopApiService,
@@ -63,6 +68,39 @@ export function setupModules() {
}, },
}; };
}); });
framework.scope(ServerScope).override(AuthProvider, p => {
const apis = p.get(DesktopApiService).api;
const serverService = p.get(ServerService);
const endpoint = serverService.server.baseUrl;
return {
async signInMagicLink(email, token, clientNonce) {
await apis.handler.auth.signInMagicLink(
endpoint,
email,
token,
clientNonce
);
},
async signInOauth(code, state, _provider, clientNonce) {
return await apis.handler.auth.signInOauth(
endpoint,
code,
state,
clientNonce
);
},
async signInPassword(credential) {
await apis.handler.auth.signInPassword(endpoint, credential);
},
async signInOpenAppSignInCode(code) {
await apis.handler.auth.signInOpenAppSignInCode(endpoint, code);
},
async signOut() {
await apis.handler.auth.signOut(endpoint);
},
};
});
const frameworkProvider = framework.provider(); const frameworkProvider = framework.provider();
@@ -2,7 +2,10 @@ import '@affine/core/bootstrap/electron';
import { apis } from '@affine/electron-api'; import { apis } from '@affine/electron-api';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel'; import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud'; import {
cloudStorages,
configureSocketAuthMethod,
} from '@affine/nbstore/cloud';
import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite'; import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite';
import { import {
bindNativeDBV1Apis, bindNativeDBV1Apis,
@@ -18,6 +21,15 @@ import { OpConsumer } from '@toeverything/infra/op';
bindNativeDBApis(apis!.nbstore); bindNativeDBApis(apis!.nbstore);
// oxlint-disable-next-line no-non-null-assertion // oxlint-disable-next-line no-non-null-assertion
bindNativeDBV1Apis(apis!.db); bindNativeDBV1Apis(apis!.db);
configureSocketAuthMethod((endpoint, cb) => {
// oxlint-disable-next-line no-non-null-assertion
apis!.auth
.readEndpointToken(endpoint)
.then(({ token }: { token?: string | null }) => {
cb(token ? { token, tokenType: 'jwt' } : {});
})
.catch(() => cb({}));
});
const storeManager = new StoreManagerConsumer([ const storeManager = new StoreManagerConsumer([
...sqliteStorages, ...sqliteStorages,
@@ -0,0 +1,177 @@
import { net, session } from 'electron';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
deleteNativeAuthToken,
getNativeAuthToken,
setNativeAuthToken,
} from './native-token';
interface SignInResponse {
exchangeCode?: string;
redirectUri?: string;
}
interface ExchangeResponse {
token?: string;
}
const authCookieNames = [
'affine_session',
'affine_user_id',
'affine_csrf_token',
];
function authUrl(endpoint: string, path: string) {
return new URL(path, endpoint).toString();
}
async function readJson<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(text || response.statusText);
}
return text ? JSON.parse(text) : ({} as T);
}
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
return await net.fetch(authUrl(endpoint, path), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
async function clearAuthCookies(endpoint: string) {
await Promise.all(
authCookieNames.map(name =>
session.defaultSession.cookies
.remove(endpoint, name)
.catch(error =>
logger.debug(
'failed to clear native auth cookie',
endpoint,
name,
error
)
)
)
);
}
async function exchangeSession(endpoint: string, response: SignInResponse) {
if (!response.exchangeCode) {
throw new Error('Missing native auth exchange code.');
}
const exchangeResponse = await fetchAuth(
endpoint,
'/api/auth/native/exchange',
{ code: response.exchangeCode }
);
const body = await readJson<ExchangeResponse>(exchangeResponse);
if (!body.token) {
throw new Error('Missing native auth token.');
}
setNativeAuthToken(endpoint, body.token);
await clearAuthCookies(endpoint);
}
export const authHandlers = {
signInMagicLink: async (
_,
endpoint: string,
email: string,
token: string,
clientNonce?: string
) => {
const response = await fetchAuth(endpoint, '/api/auth/magic-link', {
email,
token,
client_nonce: clientNonce,
});
await exchangeSession(endpoint, await readJson(response));
},
signInOauth: async (
_,
endpoint: string,
code: string,
state: string,
clientNonce?: string
) => {
const response = await fetchAuth(endpoint, '/api/oauth/callback', {
code,
state,
client_nonce: clientNonce,
});
const body = await readJson<SignInResponse>(response);
await exchangeSession(endpoint, body);
return { redirectUri: body.redirectUri };
},
signInPassword: async (
_,
endpoint: string,
credential: {
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}
) => {
const response = await net.fetch(authUrl(endpoint, '/api/auth/sign-in'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
...(credential.verifyToken
? { 'x-captcha-token': credential.verifyToken }
: {}),
...(credential.challenge
? { 'x-captcha-challenge': credential.challenge }
: {}),
},
body: JSON.stringify({
email: credential.email,
password: credential.password,
}),
});
await exchangeSession(endpoint, await readJson(response));
},
signInOpenAppSignInCode: async (_e, endpoint: string, code: string) => {
const response = await fetchAuth(endpoint, '/api/auth/open-app/sign-in', {
code,
});
await exchangeSession(endpoint, await readJson(response));
},
signOut: async (_e, endpoint: string) => {
const token = getNativeAuthToken(endpoint);
if (token) {
await net.fetch(authUrl(endpoint, '/api/auth/sign-out'), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-affine-version': BUILD_CONFIG.appVersion,
},
});
}
deleteNativeAuthToken(endpoint);
await clearAuthCookies(endpoint);
},
readEndpointToken: async (_e, endpoint: string) => {
return { token: getNativeAuthToken(endpoint) };
},
} satisfies NamespaceHandlers;
@@ -0,0 +1,83 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { logger } from '../logger';
const FILEPATH = path.join(app.getPath('userData'), 'native-auth-tokens.json');
type TokenRecord = {
token: string;
};
function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
function readStore(): Record<string, string> {
if (!fs.existsSync(FILEPATH)) return {};
try {
return JSON.parse(fs.readFileSync(FILEPATH, 'utf-8'));
} catch (error) {
logger.error('failed to read native auth token store', error);
return {};
}
}
function writeStore(store: Record<string, string>) {
fs.writeFileSync(FILEPATH, JSON.stringify(store, null, 2));
}
function encryptToken(record: TokenRecord) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Secure native auth token storage is not available.');
}
return safeStorage.encryptString(JSON.stringify(record)).toString('base64');
}
function decryptToken(value: string): TokenRecord | null {
if (!safeStorage.isEncryptionAvailable()) {
return null;
}
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt native auth token', error);
return null;
}
}
export function setNativeAuthToken(endpoint: string, token: string) {
const store = readStore();
store[normalizeEndpoint(endpoint)] = encryptToken({ token });
writeStore(store);
}
export function deleteNativeAuthToken(endpoint: string) {
const store = readStore();
delete store[normalizeEndpoint(endpoint)];
writeStore(store);
}
export function getNativeAuthToken(endpoint: string) {
const encrypted = readStore()[normalizeEndpoint(endpoint)];
if (!encrypted) return null;
return decryptToken(encrypted)?.token ?? null;
}
export function getAuthTokenForUrl(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') {
parsed.protocol = 'http:';
} else if (parsed.protocol === 'wss:') {
parsed.protocol = 'https:';
}
return getNativeAuthToken(parsed.origin);
} catch {
return null;
}
}
@@ -2,6 +2,7 @@ import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type'; import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
import { authHandlers } from './auth/handlers';
import { byokStorageHandlers } from './byok-storage/handlers'; import { byokStorageHandlers } from './byok-storage/handlers';
import { clipboardHandlers } from './clipboard'; import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage'; import { configStorageHandlers } from './config-storage';
@@ -44,6 +45,7 @@ export const allHandlers = {
popup: popupHandlers, popup: popupHandlers,
i18n: i18nHandlers, i18n: i18nHandlers,
byokStorage: byokStorageHandlers, byokStorage: byokStorageHandlers,
auth: authHandlers,
}; };
export const registerHandlers = () => { export const registerHandlers = () => {
@@ -2,7 +2,6 @@ import path, { join } from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { app, net, protocol, session } from 'electron'; import { app, net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser';
import { anotherHost, mainHost } from '../shared/internal-origin'; import { anotherHost, mainHost } from '../shared/internal-origin';
import { import {
@@ -12,6 +11,7 @@ import {
resolvePathInBase, resolvePathInBase,
resourcesPath, resourcesPath,
} from '../shared/utils'; } from '../shared/utils';
import { getAuthTokenForUrl } from './auth/native-token';
import { buildType, isDev } from './config'; import { buildType, isDev } from './config';
import { logger } from './logger'; import { logger } from './logger';
@@ -64,7 +64,27 @@ function buildTargetUrl(base: string, urlObject: URL) {
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString(); return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
} }
function proxyRequest( async function buildAuthorizedRequest(request: Request, targetUrl: string) {
const clonedRequest = request.clone();
const headers = new Headers(clonedRequest.headers);
const token = getAuthTokenForUrl(targetUrl);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return new Request(targetUrl, {
body:
clonedRequest.method === 'GET' || clonedRequest.method === 'HEAD'
? undefined
: clonedRequest.body,
headers,
method: clonedRequest.method,
redirect: clonedRequest.redirect,
signal: clonedRequest.signal,
});
}
async function proxyRequest(
request: Request, request: Request,
urlObject: URL, urlObject: URL,
base: string, base: string,
@@ -72,12 +92,13 @@ function proxyRequest(
) { ) {
const { bypassCustomProtocolHandlers = true } = options; const { bypassCustomProtocolHandlers = true } = options;
const targetUrl = buildTargetUrl(base, urlObject); const targetUrl = buildTargetUrl(base, urlObject);
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
const proxiedRequest = bypassCustomProtocolHandlers const proxiedRequest = bypassCustomProtocolHandlers
? Object.assign(request.clone(), { ? Object.assign(authorizedRequest, {
bypassCustomProtocolHandlers: true, bypassCustomProtocolHandlers: true,
}) })
: request; : authorizedRequest;
return net.fetch(targetUrl, proxiedRequest); return net.fetch(proxiedRequest);
} }
async function handleFileRequest(request: Request) { async function handleFileRequest(request: Request) {
@@ -218,41 +239,6 @@ export function registerProtocol() {
const { responseHeaders, url } = responseDetails; const { responseHeaders, url } = responseDetails;
(async () => { (async () => {
if (responseHeaders) { if (responseHeaders) {
const originalCookie =
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
if (originalCookie) {
// save the cookies, to support third party cookies
for (const cookies of originalCookie) {
const parsedCookies = cookieParser.parse(cookies);
for (const parsedCookie of parsedCookies) {
if (!parsedCookie.value) {
await session.defaultSession.cookies.remove(
responseDetails.url,
parsedCookie.name
);
} else {
await session.defaultSession.cookies.set({
url: responseDetails.url,
domain: parsedCookie.domain,
expirationDate: parsedCookie.expires?.getTime(),
httpOnly: parsedCookie.httpOnly,
secure: parsedCookie.secure,
value: parsedCookie.value,
name: parsedCookie.name,
path: parsedCookie.path,
sameSite: parsedCookie.sameSite?.toLowerCase() as
| 'unspecified'
| 'no_restriction'
| 'lax'
| 'strict'
| undefined,
});
}
}
}
}
const { protocol, hostname } = new URL(url); const { protocol, hostname } = new URL(url);
// Adjust CORS for assets responses and allow blob redirects on affine domains // Adjust CORS for assets responses and allow blob redirects on affine domains
@@ -284,23 +270,17 @@ export function registerProtocol() {
const url = new URL(details.url); const url = new URL(details.url);
(async () => { (async () => {
// session cookies are set to assets:// on production
// if sending request to the cloud, attach the session cookie (to affine cloud server)
if ( if (
url.protocol === 'http:' || url.protocol === 'http:' ||
url.protocol === 'https:' || url.protocol === 'https:' ||
url.protocol === 'ws:' || url.protocol === 'ws:' ||
url.protocol === 'wss:' url.protocol === 'wss:'
) { ) {
const cookies = await session.defaultSession.cookies.get({ const token = getAuthTokenForUrl(details.url);
url: details.url, if (token) {
}); delete details.requestHeaders.authorization;
details.requestHeaders.Authorization = `Bearer ${token}`;
const cookieString = cookies }
.map(c => `${c.name}=${c.value}`)
.join('; ');
delete details.requestHeaders['cookie'];
details.requestHeaders['Cookie'] = cookieString;
} }
const hostname = url.hostname; const hostname = url.hostname;
@@ -1,5 +1,6 @@
import Capacitor import Capacitor
import Foundation import Foundation
import Security
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AuthPlugin" public let identifier = "AuthPlugin"
@@ -7,10 +8,70 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteEndpointToken", returnType: CAPPluginReturnPromise),
] ]
private let tokenService = "app.affine.pro.auth-token"
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
private func canonicalEndpoint(_ endpoint: String) -> String {
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
return endpoint
}
let normalizedScheme = scheme.lowercased()
let normalizedHost = host.lowercased()
let defaultPort: Int?
if normalizedScheme == "http" {
defaultPort = 80
} else if normalizedScheme == "https" {
defaultPort = 443
} else {
defaultPort = nil
}
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
return "\(normalizedScheme)://\(normalizedHost)\(port)"
}
@objc public func readEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
if let token = try self.readToken(endpoint) {
call.resolve(["token": token])
} else {
call.resolve(["token": NSNull()])
}
} catch {
call.reject("Failed to read endpoint token, \(error)", nil, error)
}
}
@objc public func writeEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
let token = try call.getStringEnsure("token")
try self.writeToken(endpoint, token)
call.resolve(["ok": true])
} catch {
call.reject("Failed to write endpoint token, \(error)", nil, error)
}
}
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
try self.deleteToken(endpoint)
call.resolve(["ok": true])
} catch {
call.reject("Failed to delete endpoint token, \(error)", nil, error)
}
}
@objc public func signInMagicLink(_ call: CAPPluginCall) { @objc public func signInMagicLink(_ call: CAPPluginCall) {
Task { Task {
do { do {
@@ -19,7 +80,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
let token = try call.getStringEnsure("token") let token = try call.getStringEnsure("token")
let clientNonce = call.getString("clientNonce") let clientNonce = call.getString("clientNonce")
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce]) let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/magic-link",
headers: [
"x-affine-client-kind": "native"
], body: ["email": email, "token": token, "client_nonce": clientNonce])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { if let textBody = String(data: data, encoding: .utf8) {
@@ -30,12 +95,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return return
} }
guard let token = try self.tokenFromCookie(endpoint) else { call.resolve(["token": try await self.exchangeSession(endpoint, data)])
call.reject("token not found")
return
}
call.resolve(["token": token])
} catch { } catch {
call.reject("Failed to sign in, \(error)", nil, error) call.reject("Failed to sign in, \(error)", nil, error)
} }
@@ -50,7 +110,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
let state = try call.getStringEnsure("state") let state = try call.getStringEnsure("state")
let clientNonce = call.getString("clientNonce") let clientNonce = call.getString("clientNonce")
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce]) let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/oauth/callback",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code, "state": state, "client_nonce": clientNonce])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { if let textBody = String(data: data, encoding: .utf8) {
@@ -61,12 +125,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return return
} }
guard let token = try self.tokenFromCookie(endpoint) else { call.resolve(["token": try await self.exchangeSession(endpoint, data)])
call.reject("token not found")
return
}
call.resolve(["token": token])
} catch { } catch {
call.reject("Failed to sign in, \(error)", nil, error) call.reject("Failed to sign in, \(error)", nil, error)
} }
@@ -82,10 +141,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
let verifyToken = call.getString("verifyToken") let verifyToken = call.getString("verifyToken")
let challenge = call.getString("challenge") let challenge = call.getString("challenge")
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [ let (data, response) = try await self.fetch(
"x-captcha-token": verifyToken, endpoint, method: "POST", action: "/api/auth/sign-in",
"x-captcha-challenge": challenge, headers: [
], body: ["email": email, "password": password]) "x-affine-client-kind": "native",
"x-captcha-token": verifyToken,
"x-captcha-challenge": challenge,
], body: ["email": email, "password": password])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { if let textBody = String(data: data, encoding: .utf8) {
@@ -96,12 +158,35 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return return
} }
guard let token = try self.tokenFromCookie(endpoint) else { call.resolve(["token": try await self.exchangeSession(endpoint, data)])
call.reject("token not found") } catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
}
}
@objc public func signInOpenApp(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let code = try call.getStringEnsure("code")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/open-app/sign-in",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code])
if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) {
call.reject(textBody)
} else {
call.reject("Failed to sign in")
}
return return
} }
call.resolve(["token": token]) call.resolve(["token": try await self.exchangeSession(endpoint, data)])
} catch { } catch {
call.reject("Failed to sign in, \(error)", nil, error) call.reject("Failed to sign in, \(error)", nil, error)
} }
@@ -112,11 +197,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
Task { Task {
do { do {
let endpoint = try call.getStringEnsure("endpoint") let endpoint = try call.getStringEnsure("endpoint")
let csrfToken = try self.csrfTokenFromCookie(endpoint) let token = call.getString("token")
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-out", headers: [ let (data, response) = try await self.fetch(
"x-affine-csrf-token": csrfToken, endpoint, method: "POST", action: "/api/auth/sign-out",
], body: nil) headers: [
"Authorization": token.map { "Bearer \($0)" }
], body: nil)
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { if let textBody = String(data: data, encoding: .utf8) {
@@ -127,6 +214,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return return
} }
self.clearAuthCookies(endpoint)
call.resolve(["ok": true]) call.resolve(["ok": true])
} catch { } catch {
call.reject("Failed to sign out, \(error)", nil, error) call.reject("Failed to sign out, \(error)", nil, error)
@@ -134,38 +222,147 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
} }
} }
private func tokenFromCookie(_ endpoint: String) throws -> String? { private func tokenFromResponse(_ data: Data) throws -> String {
guard let endpointUrl = URL(string: endpoint) else { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
throw AuthError.invalidEndpoint let token = json["token"] as? String
else {
throw AuthError.tokenNotFound
} }
if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { return token
$0.name == "affine_session" }
}) {
return cookie.value private func exchangeCodeFromResponse(_ data: Data) throws -> String {
} else { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
return nil let code = json["exchangeCode"] as? String
else {
throw AuthError.exchangeCodeNotFound
}
return code
}
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws -> String {
let code = try exchangeCodeFromResponse(signInData)
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/native/exchange",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code])
if response.statusCode >= 400 {
throw AuthError.exchangeFailed
}
let token = try tokenFromResponse(data)
self.clearAuthCookies(endpoint)
return token
}
private func clearAuthCookies(_ endpoint: String) {
guard let url = URL(string: endpoint), let host = url.host else {
return
}
let normalizedHost = host.lowercased()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
if domainMatches && authCookieNames.contains(cookie.name) {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
} }
} }
private func csrfTokenFromCookie(_ endpoint: String) throws -> String? { private func tokenQuery(_ endpoint: String) -> [String: Any] {
guard let endpointUrl = URL(string: endpoint) else { [
throw AuthError.invalidEndpoint kSecClass as String: kSecClassGenericPassword,
} kSecAttrService as String: tokenService,
kSecAttrAccount as String: canonicalEndpoint(endpoint),
return HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { ]
$0.name == "affine_csrf_token"
})?.value
} }
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) { private func legacyTokenQuery(_ endpoint: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenService,
kSecAttrAccount as String: endpoint,
]
}
private func readToken(_ endpoint: String) throws -> String? {
var query = tokenQuery(endpoint)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
guard canonicalEndpoint(endpoint) != endpoint else {
return nil
}
var legacyQuery = legacyTokenQuery(endpoint)
legacyQuery[kSecReturnData as String] = true
legacyQuery[kSecMatchLimit as String] = kSecMatchLimitOne
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &item)
if legacyStatus == errSecItemNotFound {
return nil
}
guard legacyStatus == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
let token = String(data: data, encoding: .utf8)
if let token = token {
try writeToken(endpoint, token)
let deleteStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw AuthError.internalError
}
}
return token
}
guard status == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
return String(data: data, encoding: .utf8)
}
private func writeToken(_ endpoint: String, _ token: String) throws {
try deleteToken(endpoint)
var query = tokenQuery(endpoint)
query[kSecValueData as String] = Data(token.utf8)
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw AuthError.internalError
}
}
private func deleteToken(_ endpoint: String) throws {
let status = SecItemDelete(tokenQuery(endpoint) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AuthError.internalError
}
if canonicalEndpoint(endpoint) != endpoint {
let legacyStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
guard legacyStatus == errSecSuccess || legacyStatus == errSecItemNotFound else {
throw AuthError.internalError
}
}
}
private func fetch(
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
) async throws -> (Data, HTTPURLResponse) {
guard let targetUrl = URL(string: "\(endpoint)\(action)") else { guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
throw AuthError.invalidEndpoint throw AuthError.invalidEndpoint
} }
var request = URLRequest(url: targetUrl) var request = URLRequest(url: targetUrl)
request.httpMethod = method request.httpMethod = method
request.httpShouldHandleCookies = true request.httpShouldHandleCookies = false
for (key, value) in headers { for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key) request.setValue(value, forHTTPHeaderField: key)
} }
@@ -174,7 +371,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
request.httpBody = try JSONEncoder().encode(body!) request.httpBody = try JSONEncoder().encode(body!)
} }
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version") request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
request.timeoutInterval = 10 // time out 10s request.timeoutInterval = 10 // time out 10s
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
@@ -185,5 +382,5 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
} }
enum AuthError: Error { enum AuthError: Error {
case invalidEndpoint, internalError case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed
} }
@@ -57,7 +57,7 @@ public class GetCurrentUserQuery: GraphQLQuery {
public var emailVerified: Bool { __data["emailVerified"] } public var emailVerified: Bool { __data["emailVerified"] }
/// User avatar url /// User avatar url
public var avatarUrl: String? { __data["avatarUrl"] } public var avatarUrl: String? { __data["avatarUrl"] }
@available(*, deprecated, message: "use [/api/auth/sign-in?native=true] instead") @available(*, deprecated, message: "use native session exchange instead")
public var token: Token { __data["token"] } public var token: Token { __data["token"] }
/// CurrentUser.Token /// CurrentUser.Token
+35 -13
View File
@@ -74,7 +74,11 @@ import { Hashcash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore'; import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall'; import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview'; import { Preview } from './plugins/preview';
import { writeEndpointToken } from './proxy'; import {
deleteEndpointToken,
readEndpointToken,
writeEndpointToken,
} from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control'; import { enableNavigationGesture$ } from './web-navigation-control';
const storeManagerClient = createStoreManagerClient(); const storeManagerClient = createStoreManagerClient();
@@ -204,10 +208,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
}); });
await writeEndpointToken(endpoint, token); await writeEndpointToken(endpoint, token);
}, },
async signOut() { async signInOpenAppSignInCode(code) {
await Auth.signOut({ const { token } = await Auth.signInOpenApp({
endpoint, endpoint,
code,
}); });
await writeEndpointToken(endpoint, token);
},
async signOut() {
const token = await readEndpointToken(endpoint);
try {
await Auth.signOut({ endpoint, token });
} finally {
await deleteEndpointToken(endpoint);
}
}, },
}; };
}); });
@@ -541,13 +555,9 @@ function createStoreManagerClient() {
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, { AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
channel: { channel: {
on(listener) { on(listener) {
const f = (e: MessageEvent<any>) => { const f = (e: MessageEvent<any>) => listener(e.data);
listener(e.data);
};
nativeDBApiChannelServer.addEventListener('message', f); nativeDBApiChannelServer.addEventListener('message', f);
return () => { return () => nativeDBApiChannelServer.removeEventListener('message', f);
nativeDBApiChannelServer.removeEventListener('message', f);
};
}, },
send(data) { send(data) {
nativeDBApiChannelServer.postMessage(data); nativeDBApiChannelServer.postMessage(data);
@@ -557,11 +567,23 @@ function createStoreManagerClient() {
}); });
nativeDBApiChannelServer.start(); nativeDBApiChannelServer.start();
worker.postMessage( worker.postMessage(
{ { type: 'native-db-api-channel', port: nativeDBApiChannelClient },
type: 'native-db-api-channel',
port: nativeDBApiChannelClient,
},
[nativeDBApiChannelClient] [nativeDBApiChannelClient]
); );
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
new MessageChannel();
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
readEndpointToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
});
authTokenChannelServer.start();
worker.postMessage(
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
);
return new StoreManagerClient(new OpClient(worker)); return new StoreManagerClient(new OpClient(worker));
} }
@@ -18,19 +18,28 @@ import {
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc';
import { readEndpointToken } from './proxy'; let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
configureSocketAuthMethod((endpoint, cb) => { configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint) readEndpointToken(endpoint)
.then(token => { .then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
cb({ token }); .catch(() => cb({}));
})
.catch(e => {
console.error(e);
});
}); });
globalThis.addEventListener('message', e => { globalThis.addEventListener('message', e => {
if (e.data.type === 'native-auth-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token } = e.data as { id?: string; token?: string | null };
if (!id) return;
pendingTokenRequests.get(id)?.(token ?? null);
pendingTokenRequests.delete(id);
});
authTokenPort.start();
return;
}
if (e.data.type === 'native-db-api-channel') { if (e.data.type === 'native-db-api-channel') {
const port = e.ports[0] as MessagePort; const port = e.ports[0] as MessagePort;
const rpc = AsyncCall<NativeDBApis>( const rpc = AsyncCall<NativeDBApis>(
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
} }
}); });
function readEndpointToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>(resolve => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
resolve(null);
}, 5000);
pendingTokenRequests.set(id, token => {
clearTimeout(timeout);
resolve(token);
});
authTokenPort?.postMessage({ id, endpoint });
});
}
const consumer = new OpConsumer<WorkerManagerOps>( const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable globalThis as MessageCommunicapable
); );
@@ -18,5 +18,17 @@ export interface AuthPlugin {
verifyToken?: string; verifyToken?: string;
challenge?: string; challenge?: string;
}): Promise<{ token: string }>; }): Promise<{ token: string }>;
signOut(options: { endpoint: string }): Promise<void>; signInOpenApp(options: {
endpoint: string;
code: string;
}): Promise<{ token: string }>;
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
readEndpointToken(options: {
endpoint: string;
}): Promise<{ token?: string | null }>;
writeEndpointToken(options: {
endpoint: string;
token: string;
}): Promise<void>;
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
} }
+53 -24
View File
@@ -1,4 +1,19 @@
import { openDB } from 'idb'; import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(url, globalThis.location.origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
function canonicalEndpoint(endpoint: string) {
return authEndpointForUrl(endpoint) ?? endpoint;
}
/** /**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview. * the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init); const request = new Request(input, init);
const origin = new URL(request.url, globalThis.location.origin).origin; const origin = authEndpointForUrl(request.url);
const token = await readEndpointToken(origin); const token = origin
? await readEndpointToken(origin).catch(() => null)
: null;
if (token) { if (token) {
request.headers.set('Authorization', `Bearer ${token}`); request.headers.set('Authorization', `Bearer ${token}`);
} }
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
}; };
const rawXMLHttpRequest = globalThis.XMLHttpRequest; const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
override send(body?: Document | XMLHttpRequestBodyInit | null): void { override open(
const origin = new URL(this.responseURL, globalThis.location.origin).origin; method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
readEndpointToken(origin).then( override send(body?: Document | XMLHttpRequestBodyInit | null): void {
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
token => { token => {
if (token) { if (token) {
this.setRequestHeader('Authorization', `Bearer ${token}`); this.setRequestHeader('Authorization', `Bearer ${token}`);
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
return super.send(body); return super.send(body);
}, },
() => { () => {
throw new Error('Failed to read token'); return super.send(body);
} }
); );
} }
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
export async function readEndpointToken( export async function readEndpointToken(
endpoint: string endpoint: string
): Promise<string | null> { ): Promise<string | null> {
const idb = await openDB('affine-token', 1, { const { token } = await Auth.readEndpointToken({
upgrade(db) { endpoint: canonicalEndpoint(endpoint),
if (!db.objectStoreNames.contains('tokens')) {
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
}); });
return token ?? null;
const token = await idb.get('tokens', endpoint);
return token ? token.token : null;
} }
export async function writeEndpointToken(endpoint: string, token: string) { export async function writeEndpointToken(endpoint: string, token: string) {
const db = await openDB('affine-token', 1, { await Auth.writeEndpointToken({
upgrade(db) { endpoint: canonicalEndpoint(endpoint),
if (!db.objectStoreNames.contains('tokens')) { token,
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
}); });
}
await db.put('tokens', { endpoint, token });
export async function deleteEndpointToken(endpoint: string) {
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
} }
@@ -41,7 +41,6 @@ describe('AuthService oauthPreflight', () => {
framework.service(NbstoreService, { framework.service(NbstoreService, {
realtime: { subscribe: () => of() }, realtime: { subscribe: () => of() },
} as any); } as any);
framework.service(AuthService, [ framework.service(AuthService, [
FetchService, FetchService,
AuthStore, AuthStore,
@@ -16,6 +16,7 @@ export const Captcha = () => {
const handleTurnstileSuccess = useCallback( const handleTurnstileSuccess = useCallback(
(token: string) => { (token: string) => {
captchaService.challenge$.next(undefined);
captchaService.verifyToken$.next(token); captchaService.verifyToken$.next(token);
}, },
[captchaService] [captchaService]
@@ -86,7 +86,6 @@ export const SignInWithEmailStep = ({
setIsSending(true); setIsSending(true);
try { try {
setResendCountDown(60); setResendCountDown(60);
captchaService.revalidate();
await authService.sendEmailMagicLink( await authService.sendEmailMagicLink(
email, email,
verifyToken, verifyToken,
@@ -100,6 +99,7 @@ export const SignInWithEmailStep = ({
title: 'Failed to sign in', title: 'Failed to sign in',
message: t[`error.${error.name}`](error.data), message: t[`error.${error.name}`](error.data),
}); });
captchaService.revalidate();
} }
setIsSending(false); setIsSending(false);
}, [ }, [
@@ -182,6 +182,7 @@ export const SignInWithEmailStep = ({
errorHint={otpError} errorHint={otpError}
onEnter={onContinue} onEnter={onContinue}
type="text" type="text"
autoComplete="one-time-code"
required={true} required={true}
maxLength={6} maxLength={6}
/> />
@@ -85,7 +85,6 @@ export const SignInWithPasswordStep = ({
setIsLoading(true); setIsLoading(true);
try { try {
captchaService.revalidate();
await authService.signInPassword({ await authService.signInPassword({
email, email,
password, password,
@@ -111,6 +110,7 @@ export const SignInWithPasswordStep = ({
: t[`error.${error.name}`](error.data), : t[`error.${error.name}`](error.data),
}); });
} }
captchaService.revalidate();
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -138,28 +138,50 @@ export const SignInWithPasswordStep = ({
/> />
<AuthContent> <AuthContent>
<AuthInput <form
label={t['com.affine.settings.email']()} onSubmit={event => {
disabled={true} event.preventDefault();
value={email} onSignIn();
/>
<AuthInput
autoFocus
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
onChange={(value: string) => {
setPassword(value);
if (passwordError) {
setPasswordError(false);
setPasswordErrorHint(t['com.affine.auth.password.error']());
}
}} }}
error={passwordError} >
errorHint={passwordErrorHint} <AuthInput
onEnter={onSignIn} label={t['com.affine.settings.email']()}
/> readOnly={true}
value={email}
type="email"
name="username"
autoComplete="username"
/>
<AuthInput
autoFocus
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
name="password"
autoComplete="current-password"
onChange={(value: string) => {
setPassword(value);
if (passwordError) {
setPasswordError(false);
setPasswordErrorHint(t['com.affine.auth.password.error']());
}
}}
error={passwordError}
errorHint={passwordErrorHint}
onEnter={onSignIn}
/>
{!verifyToken && needCaptcha && <Captcha />}
<Button
data-testid="sign-in-button"
variant="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={isLoading || (!verifyToken && needCaptcha)}
>
{t['com.affine.auth.sign.in']()}
</Button>
</form>
{!isSelfhosted && ( {!isSelfhosted && (
<div className={styles.passwordButtonRow}> <div className={styles.passwordButtonRow}>
<a <a
@@ -171,17 +193,6 @@ export const SignInWithPasswordStep = ({
</a> </a>
</div> </div>
)} )}
{!verifyToken && needCaptcha && <Captcha />}
<Button
data-testid="sign-in-button"
variant="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={isLoading || (!verifyToken && needCaptcha)}
onClick={onSignIn}
>
{t['com.affine.auth.sign.in']()}
</Button>
</AuthContent> </AuthContent>
<AuthFooter> <AuthFooter>
<Back changeState={changeState} /> <Back changeState={changeState} />
@@ -90,7 +90,9 @@ export const SignInStep = ({
setIsMutating(true); setIsMutating(true);
try { try {
const { hasPassword } = await authService.checkUserByEmail(email); const { methods } = await authService.checkUserByEmail(email);
const hasPassword = methods.password.available;
const canUseMagicLink = methods.magicLink.available;
if (hasPassword) { if (hasPassword) {
changeState(prev => ({ changeState(prev => ({
@@ -99,13 +101,18 @@ export const SignInStep = ({
step: 'signInWithPassword', step: 'signInWithPassword',
hasPassword: true, hasPassword: true,
})); }));
} else { } else if (canUseMagicLink) {
changeState(prev => ({ changeState(prev => ({
...prev, ...prev,
email, email,
step: 'signInWithEmail', step: 'signInWithEmail',
hasPassword: false, hasPassword: false,
})); }));
} else {
notify.error({
title: 'Failed to sign in',
message: 'This email is not available for sign in.',
});
} }
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
@@ -151,31 +158,41 @@ export const SignInStep = ({
<AuthContent> <AuthContent>
<OAuth redirectUrl={state.redirectUrl} /> <OAuth redirectUrl={state.redirectUrl} />
<AuthInput <form
className={style.authInput} onSubmit={event => {
label={t['com.affine.settings.email']()} event.preventDefault();
placeholder={t['com.affine.auth.sign.email.placeholder']()} onContinue();
onChange={setEmail} }}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
<Button
className={style.signInButton}
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
onClick={onContinue}
> >
{t['com.affine.auth.sign.email.continue']()} <AuthInput
</Button> className={style.authInput}
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
type="email"
name="username"
autoComplete="username"
/>
<Button
className={style.signInButton}
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
disabled={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
</form>
{!isSelfhosted && ( {!isSelfhosted && (
<> <>
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
export const ChangePasswordDialog = ({ export const ChangePasswordDialog = ({
close, close,
hasPassword: hasPasswordProp,
server: serverBaseUrl, server: serverBaseUrl,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['change-password']>) => { }: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['change-password']>) => {
const t = useI18n(); const t = useI18n();
@@ -44,7 +45,8 @@ export const ChangePasswordDialog = ({
const authService = server.scope.get(AuthService); const authService = server.scope.get(AuthService);
const account = useLiveData(authService.session.account$); const account = useLiveData(authService.session.account$);
const email = account?.email; const email = account?.email;
const hasPassword = account?.info?.hasPassword; const hasPassword =
hasPasswordProp ?? account?.info?.authMethods?.password.bound ?? false;
const [hasSentEmail, setHasSentEmail] = useState(false); const [hasSentEmail, setHasSentEmail] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const passwordLimits = useLiveData( const passwordLimits = useLiveData(
@@ -201,13 +201,19 @@ export const AccountSetting = ({
const onPasswordButtonClick = useCallback(() => { const onPasswordButtonClick = useCallback(() => {
globalDialogService.open('change-password', { globalDialogService.open('change-password', {
hasPassword: account?.info?.authMethods?.password.bound,
server: serverService.server.baseUrl, server: serverService.server.baseUrl,
}); });
}, [globalDialogService, serverService.server.baseUrl]); }, [
account?.info?.authMethods?.password.bound,
globalDialogService,
serverService.server.baseUrl,
]);
if (!account) { if (!account) {
return null; return null;
} }
const hasPassword = account.info?.authMethods?.password.bound;
return ( return (
<> <>
@@ -233,7 +239,7 @@ export const AccountSetting = ({
desc={t['com.affine.settings.password.message']()} desc={t['com.affine.settings.password.message']()}
> >
<Button onClick={onPasswordButtonClick}> <Button onClick={onPasswordButtonClick}>
{account.info?.hasPassword {hasPassword
? t['com.affine.settings.password.action.change']() ? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()} : t['com.affine.settings.password.action.set']()}
</Button> </Button>
@@ -79,6 +79,13 @@ export function configureDefaultAuthProvider(framework: Framework) {
}, },
}); });
}, },
async signInOpenAppSignInCode(code: string) {
await fetchService.fetch('/api/auth/open-app/sign-in', {
method: 'POST',
body: JSON.stringify({ code }),
headers: { 'content-type': 'application/json' },
});
},
async signOut() { async signOut() {
const csrfToken = getCookieValue(CSRF_COOKIE_NAME); const csrfToken = getCookieValue(CSRF_COOKIE_NAME);
await fetchService.fetch('/api/auth/sign-out', { await fetchService.fetch('/api/auth/sign-out', {
@@ -21,6 +21,8 @@ export interface AuthProvider {
challenge?: string; challenge?: string;
}): Promise<void>; }): Promise<void>;
signInOpenAppSignInCode(code: string): Promise<void>;
signOut(): Promise<void>; signOut(): Promise<void>;
} }
@@ -222,11 +222,7 @@ export class AuthService extends Service {
} }
async signInOpenAppSignInCode(code: string) { async signInOpenAppSignInCode(code: string) {
await this.fetchService.fetch('/api/auth/open-app/sign-in', { await this.store.signInOpenAppSignInCode(code);
method: 'POST',
body: JSON.stringify({ code }),
headers: { 'content-type': 'application/json' },
});
this.session.revalidate(); this.session.revalidate();
} }
@@ -33,7 +33,7 @@ export class CaptchaService extends Service {
revalidate = effect( revalidate = effect(
exhaustMap(() => { exhaustMap(() => {
return fromPromise(async signal => { return fromPromise(async signal => {
if (!this.needCaptcha$.value) { if (!this.needCaptcha$.value || !this.validatorProvider) {
return {}; return {};
} }
const res = await this.fetchService.fetch('/api/auth/challenge', { const res = await this.fetchService.fetch('/api/auth/challenge', {
@@ -46,17 +46,14 @@ export class CaptchaService extends Service {
if (!data || !data.challenge || !data.resource) { if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge'); throw new Error('Invalid challenge');
} }
if (this.validatorProvider) { const token = await this.validatorProvider.validate(
const token = await this.validatorProvider.validate( data.challenge,
data.challenge, data.resource
data.resource );
); return {
return { token,
token, challenge: data.challenge,
challenge: data.challenge, };
};
}
return { challenge: data.challenge, token: undefined };
}).pipe( }).pipe(
tap(({ challenge, token }) => { tap(({ challenge, token }) => {
this.verifyToken$.next(token); this.verifyToken$.next(token);
@@ -19,6 +19,11 @@ export interface AccountProfile {
email: string; email: string;
name: string; name: string;
hasPassword: boolean; hasPassword: boolean;
authMethods?: {
password: { bound: boolean };
oauth: { bound: boolean; providers: string[] };
passkey: { bound: boolean; count: number };
};
avatarUrl: string | null; avatarUrl: string | null;
emailVerified: string | null; emailVerified: string | null;
features?: string[]; features?: string[];
@@ -61,16 +66,20 @@ export class AuthStore extends Store {
} }
async fetchSession() { async fetchSession() {
const { user } = await this.nbstoreService.realtime.request( const { user } = await this.fetchService
'user.profile.get', .fetch('/api/auth/session')
{}, .then(res => res.json());
{ timeoutMs: 10000 } const authMethods = user
); ? await this.fetchService
.fetch('/api/auth/methods')
.then(res => (res.ok ? res.json() : undefined))
: undefined;
return { return {
user: user user: user
? { ? {
...user, ...user,
hasPassword: Boolean(user.hasPassword), hasPassword: Boolean(user.hasPassword),
authMethods,
emailVerified: user.emailVerified ? 'true' : null, emailVerified: user.emailVerified ? 'true' : null,
} }
: null, : null,
@@ -103,6 +112,10 @@ export class AuthStore extends Store {
await this.authProvider.signInPassword(credential); await this.authProvider.signInPassword(credential);
} }
async signInOpenAppSignInCode(code: string) {
await this.authProvider.signInOpenAppSignInCode(code);
}
async signOut() { async signOut() {
await this.authProvider.signOut(); await this.authProvider.signOut();
await this.nbstoreService.realtime.configure({ await this.nbstoreService.realtime.configure({
@@ -155,8 +168,12 @@ export class AuthStore extends Store {
const data = (await res.json()) as { const data = (await res.json()) as {
registered: boolean; registered: boolean;
hasPassword: boolean; methods: {
magicLink: boolean; password: { available: boolean };
magicLink: { available: boolean };
oauth: { available: boolean; providers: string[] };
passkey: { available: boolean; discoverable: boolean };
};
}; };
return data; return data;
@@ -30,7 +30,10 @@ export type GLOBAL_DIALOG_SCHEMA = {
snapshotUrl: string; snapshotUrl: string;
}) => void; }) => void;
'sign-in': (props: { server?: string; step?: string }) => void; 'sign-in': (props: { server?: string; step?: string }) => void;
'change-password': (props: { server?: string }) => void; 'change-password': (props: {
server?: string;
hasPassword?: boolean;
}) => void;
'verify-email': (props: { server?: string; changeEmail?: boolean }) => void; 'verify-email': (props: { server?: string; changeEmail?: boolean }) => void;
'enable-cloud': (props: { 'enable-cloud': (props: {
workspaceId: string; workspaceId: string;
@@ -12,7 +12,7 @@
"fr": 97, "fr": 97,
"hi": 1, "hi": 1,
"it": 94, "it": 94,
"ja": 93, "ja": 92,
"kk": 100, "kk": 100,
"ko": 93, "ko": 93,
"nb-NO": 46, "nb-NO": 46,
@@ -20,9 +20,9 @@
"pt-BR": 92, "pt-BR": 92,
"ru": 95, "ru": 95,
"sv-SE": 93, "sv-SE": 93,
"uk": 93, "tr": 100,
"uk": 92,
"ur": 100, "ur": 100,
"zh-Hans": 100, "zh-Hans": 100,
"zh-Hant": 93, "zh-Hant": 93
"tr": 100
} }