diff --git a/packages/backend/server/src/__tests__/auth/auth.e2e.ts b/packages/backend/server/src/__tests__/auth/auth.e2e.ts index 32d59304fd..d72c7ae050 100644 --- a/packages/backend/server/src/__tests__/auth/auth.e2e.ts +++ b/packages/backend/server/src/__tests__/auth/auth.e2e.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import type { TestFn } from 'ava'; import ava from 'ava'; +import supertest from 'supertest'; import { changeEmail, @@ -33,6 +34,10 @@ test('change email', async t => { const u2Email = 'u2@affine.pro'; const user = await app.signupV1(u1Email); + const signedIn = await currentUser(app); + const jwt = signedIn?.token.token; + t.truthy(jwt); + await sendChangeEmail(app, u1Email, '/email-change'); const changeMail = app.mails.last('ChangeEmail'); @@ -77,7 +82,16 @@ test('change email', async t => { t.is(changedMail.to, u2Email); t.is(changedMail.props.to, u2Email); - await app.logout(); + const revokedCookieSession = await currentUser(app); + t.is(revokedCookieSession, null); + + const revokedJwtSession = await supertest(app.getHttpServer()) + .get('/api/auth/session') + .set('Authorization', `Bearer ${jwt}`) + .expect(200); + t.falsy(revokedJwtSession.body.user); + + app.clearAuth(); await app.login({ ...user, email: u2Email, diff --git a/packages/backend/server/src/__tests__/auth/challenge-store.spec.ts b/packages/backend/server/src/__tests__/auth/challenge-store.spec.ts new file mode 100644 index 0000000000..602ac82c96 --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/challenge-store.spec.ts @@ -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' }); +}); diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts index a38cbaa265..feca0c8105 100644 --- a/packages/backend/server/src/__tests__/auth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts @@ -3,11 +3,17 @@ import { IncomingMessage } from 'node:http'; import { HttpStatus } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; +import ava, { ExecutionContext, TestFn } from 'ava'; import Sinon from 'sinon'; import supertest from 'supertest'; -import { parseCookies as safeParseCookies } from '../../base/utils/request'; +import { ConfigFactory } from '../../base'; +import { + getRequestCookie, + getRequestHeader, + parseCookies as safeParseCookies, +} from '../../base/utils/request'; +import { MagicLinkAuthService } from '../../core/auth/magic-link'; import { AuthService } from '../../core/auth/service'; import { createTestingApp, @@ -18,7 +24,9 @@ import { const test = ava as TestFn<{ auth: AuthService; + magicLink: MagicLinkAuthService; db: PrismaClient; + config: ConfigFactory; app: TestingApp; }>; @@ -26,13 +34,18 @@ test.before(async t => { const app = await createTestingApp(); t.context.auth = app.get(AuthService); + t.context.magicLink = app.get(MagicLinkAuthService); t.context.db = app.get(PrismaClient); + t.context.config = app.get(ConfigFactory); t.context.app = app; }); test.beforeEach(async t => { Sinon.reset(); await t.context.app.initTestingDB(); + t.context.config.override({ + auth: { allowSignup: true, requireEmailDomainVerification: false }, + }); }); test.after.always(async t => { @@ -44,15 +57,102 @@ test('should be able to sign in with credential', async t => { const u1 = await app.createUser('u1@affine.pro'); - await app + const res = await app .POST('/api/auth/sign-in') .send({ email: u1.email, password: u1.password }) .expect(200); + t.is(res.body.id, u1.id); + t.falsy(res.body.token); + t.falsy(res.body.expiresAt); + const session = await currentUser(app); t.is(session?.id, u1.id); }); +async function exchangeSession(app: TestingApp, code: string) { + return await supertest(app.getHttpServer()) + .post('/api/auth/native/exchange') + .set('x-affine-client-kind', 'native') + .send({ code }) + .expect(201); +} + +function assertClearsNativeAuthCookies( + t: ExecutionContext, + res: supertest.Response +) { + const setCookies = res.get('Set-Cookie') ?? []; + for (const name of [ + AuthService.sessionCookieName, + AuthService.userCookieName, + AuthService.csrfCookieName, + ]) { + t.true( + setCookies.some( + cookie => + cookie.startsWith(`${name}=;`) && + /Expires=Thu, 01 Jan 1970/i.test(cookie) + ) + ); + } +} + +test('should issue exchange code only for native credential sign in', async t => { + const { app } = t.context; + + const u1 = await app.createUser('native@affine.pro'); + + const res = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: u1.email, password: u1.password }) + .expect(200); + + t.is(res.body.id, u1.id); + t.truthy(res.body.exchangeCode); + assertClearsNativeAuthCookies(t, res); + + const exchangeRes = await exchangeSession(app, res.body.exchangeCode); + t.truthy(exchangeRes.body.token); + t.truthy(exchangeRes.body.expiresAt); +}); + +test('should not issue jwt for browser-origin credential sign in', async t => { + const { app } = t.context; + + const u1 = await app.createUser('browser@affine.pro'); + + const res = await app + .POST('/api/auth/sign-in') + .set('origin', 'https://app.affine.pro') + .set('x-affine-client-kind', 'native') + .send({ email: u1.email, password: u1.password }) + .expect(200); + + t.is(res.body.id, u1.id); + t.falsy(res.body.token); + t.falsy(res.body.expiresAt); + t.falsy(res.body.exchangeCode); +}); + +test('should write legacy auth cookies when signing in with credential', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + + const res = await app + .POST('/api/auth/sign-in') + .send({ email: u1.email, password: u1.password }) + .expect(200); + + const cookies = parseCookies(res); + + t.truthy(cookies[AuthService.sessionCookieName]); + t.truthy(cookies[AuthService.userCookieName]); + t.truthy(cookies[AuthService.csrfCookieName]); +}); + test('should record sign in client version when header is provided', async t => { const { app, db } = t.context; @@ -81,6 +181,126 @@ test('should record sign in client version when header is provided', async t => t.is(userSession2?.signInClientVersion, '0.25.1'); }); +test('should return method-oriented preflight for registered password users', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + + const res = await app + .POST('/api/auth/preflight') + .send({ email: u1.email }) + .expect(201); + + t.true(res.body.registered); + t.deepEqual(res.body.methods.password, { available: true }); + t.deepEqual(res.body.methods.magicLink, { available: true }); + t.deepEqual(res.body.methods.passkey, { + available: false, + discoverable: false, + }); + t.false('hasPassword' in res.body); +}); + +test('should return method-oriented preflight for unknown users', async t => { + const { app } = t.context; + + const res = await app + .POST('/api/auth/preflight') + .send({ email: 'unknown@affine.pro' }) + .expect(201); + + t.false(res.body.registered); + t.deepEqual(res.body.methods.password, { available: false }); + t.deepEqual(res.body.methods.magicLink, { available: true }); + t.deepEqual(res.body.methods.passkey, { + available: false, + discoverable: false, + }); + t.false('hasPassword' in res.body); +}); + +test('should return password unavailable for registered users without password', async t => { + const { app } = t.context; + + const u1 = await app.createUser('passwordless@affine.pro', { + password: null, + }); + + const res = await app + .POST('/api/auth/preflight') + .send({ email: u1.email }) + .expect(201); + + t.true(res.body.registered); + t.deepEqual(res.body.methods.password, { available: false }); + t.false('hasPassword' in res.body); +}); + +test('should return methods unavailable for disabled users', async t => { + const { app } = t.context; + + const u1 = await app.createUser('disabled@affine.pro', { + disabled: true, + }); + + const res = await app + .POST('/api/auth/preflight') + .send({ email: u1.email }) + .expect(201); + + t.false(res.body.registered); + t.deepEqual(res.body.methods.password, { available: false }); + t.deepEqual(res.body.methods.magicLink, { available: false }); +}); + +test('should return magic link unavailable for unknown users when signup is disabled', async t => { + const { app, config } = t.context; + + config.override({ + auth: { + allowSignup: false, + }, + }); + + const res = await app + .POST('/api/auth/preflight') + .send({ email: 'unknown@affine.pro' }) + .expect(201); + + t.false(res.body.registered); + t.deepEqual(res.body.methods.magicLink, { available: false }); +}); + +test('should return magic link unavailable when domain verification rejects signup email', async t => { + const { app, config } = t.context; + + config.override({ + auth: { + requireEmailDomainVerification: true, + }, + }); + + const res = await app + .POST('/api/auth/preflight') + .send({ email: 'unknown+alias@affine.pro' }) + .expect(201); + + t.false(res.body.registered); + t.deepEqual(res.body.methods.magicLink, { available: false }); +}); + +test('should return bound auth methods for current account', async t => { + const { app } = t.context; + + await app.signupV1('bound-methods@affine.pro'); + + const res = await app.GET('/api/auth/methods').expect(200); + + t.deepEqual(res.body.password, { bound: true }); + t.deepEqual(res.body.oauth, { bound: false, providers: [] }); + t.deepEqual(res.body.passkey, { bound: false, count: 0 }); +}); + test('should be able to sign in with email', async t => { const { app } = t.context; @@ -100,7 +320,19 @@ test('should be able to sign in with email', async t => { const email = url.searchParams.get('email'); const token = url.searchParams.get('token'); - await app.POST('/api/auth/magic-link').send({ email, token }).expect(201); + const signInRes = await app + .POST('/api/auth/magic-link') + .send({ email, token }) + .expect(201); + + t.is(signInRes.body.id, u1.id); + t.falsy(signInRes.body.token); + t.falsy(signInRes.body.expiresAt); + + const cookies = parseCookies(signInRes); + t.truthy(cookies[AuthService.sessionCookieName]); + t.truthy(cookies[AuthService.userCookieName]); + t.truthy(cookies[AuthService.csrfCookieName]); const session = await currentUser(app); t.is(session?.id, u1.id); @@ -140,6 +372,17 @@ test('should not be able to sign in if email is invalid', async t => { t.is(res.body.message, 'An invalid email provided: '); }); +test('should not create magic-link state if email is invalid', async t => { + const { app, magicLink } = t.context; + + await t.throwsAsync(magicLink.send('invalid-email'), { + message: 'An invalid email provided: invalid-email', + }); + + t.is(app.mails.count('SignIn'), 0); + t.is(app.mails.count('SignUp'), 0); +}); + test('should not be able to sign in if forbidden', async t => { const { app, auth } = t.context; @@ -202,7 +445,7 @@ test('should be able to sign out', async t => { t.falsy(session); }); -test('should be able to sign out when csrf header is missing (compat)', async t => { +test('should reject cookie sign out when csrf header is missing', async t => { const { app } = t.context; const u1 = await app.createUser('u1@affine.pro'); @@ -220,16 +463,134 @@ test('should be able to sign out when csrf header is missing (compat)', async t await supertest(app.getHttpServer()) .post('/api/auth/sign-out') .set('Cookie', cookieHeader) - .expect(200); + .expect(HttpStatus.FORBIDDEN); const sessionRes = await supertest(app.getHttpServer()) .get('/api/auth/session') .set('Cookie', cookieHeader) .expect(200); + t.is(sessionRes.body.user.id, u1.id); +}); + +test('should be able to sign out with jwt without csrf', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + + const signInRes = await supertest(app.getHttpServer()) + .post('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: u1.email, password: u1.password }) + .expect(200); + const token = (await exchangeSession(app, signInRes.body.exchangeCode)).body + .token; + + await supertest(app.getHttpServer()) + .post('/api/auth/sign-out') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + const sessionRes = await supertest(app.getHttpServer()) + .get('/api/auth/session') + .set('Authorization', `Bearer ${token}`) + .expect(200); + t.falsy(sessionRes.body.user); }); +test('should ignore user_id query when signing out with jwt', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + const u2 = await app.createUser('u2@affine.pro'); + + const u1SignIn = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: u1.email, password: u1.password }) + .expect(200); + const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body + .token; + await app + .POST('/api/auth/sign-in') + .send({ email: u2.email, password: u2.password }) + .expect(200); + + await supertest(app.getHttpServer()) + .post(`/api/auth/sign-out?user_id=${u2.id}`) + .set('Authorization', `Bearer ${u1Token}`) + .expect(200); + + const u1Session = await supertest(app.getHttpServer()) + .get('/api/auth/session') + .set('Authorization', `Bearer ${u1Token}`) + .expect(200); + t.falsy(u1Session.body.user); + + const cookieSession = await app.GET('/api/auth/session').expect(200); + t.is(cookieSession.body.user.id, u2.id); +}); + +test('should reuse jwt session when signing in another account without cookies', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + const u2 = await app.createUser('u2@affine.pro'); + + const u1SignIn = await supertest(app.getHttpServer()) + .post('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: u1.email, password: u1.password }) + .expect(200); + const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body + .token; + + const u2SignIn = await supertest(app.getHttpServer()) + .post('/api/auth/sign-in') + .set('Authorization', `Bearer ${u1Token}`) + .send({ email: u2.email, password: u2.password }) + .expect(200); + + const u1Session = await t.context.db.userSession.findFirstOrThrow({ + where: { userId: u1.id }, + }); + const u2Session = await t.context.db.userSession.findFirstOrThrow({ + where: { userId: u2.id }, + }); + + t.is(u2SignIn.body.id, u2.id); + t.is(u2Session.sessionId, u1Session.sessionId); +}); + +test('should not reuse legacy bearer session id when signing in another account without cookies', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + const u2 = await app.createUser('u2@affine.pro'); + + await supertest(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email, password: u1.password }) + .expect(200); + + const u1Session = await t.context.db.userSession.findFirstOrThrow({ + where: { userId: u1.id }, + }); + + await supertest(app.getHttpServer()) + .post('/api/auth/sign-in') + .set('Authorization', `Bearer ${u1Session.sessionId}`) + .send({ email: u2.email, password: u2.password }) + .expect(200); + + const u2Session = await t.context.db.userSession.findFirstOrThrow({ + where: { userId: u2.id }, + }); + + t.not(u2Session.sessionId, u1Session.sessionId); +}); + test('should be able to sign out when duplicated csrf cookies exist', async t => { const { app } = t.context; @@ -264,23 +625,6 @@ test('should be able to sign out when duplicated csrf cookies exist', async t => t.falsy(sessionRes.body.user); }); -test('should be able to sign out via GET /api/auth/sign-out (deprecated)', async t => { - const { app } = t.context; - - const u1 = await app.createUser('u1@affine.pro'); - - await app - .POST('/api/auth/sign-in') - .send({ email: u1.email, password: u1.password }) - .expect(200); - - const res = await app.GET('/api/auth/sign-out').expect(200); - t.is(res.headers.deprecation, 'true'); - - const session = await currentUser(app); - t.falsy(session); -}); - test('should reject sign out when csrf token mismatched', async t => { const { app } = t.context; @@ -317,20 +661,20 @@ test('should sign in desktop app via one-time open-app code', async t => { const exchangeRes = await supertest(app.getHttpServer()) .post('/api/auth/open-app/sign-in') + .set('x-affine-client-kind', 'native') .send({ code }) .expect(201); - const exchangedCookies = exchangeRes.get('Set-Cookie') ?? []; - t.true( - exchangedCookies.some(c => - c.startsWith(`${AuthService.sessionCookieName}=`) - ) - ); + t.is(exchangeRes.body.id, u1.id); + t.truthy(exchangeRes.body.exchangeCode); + assertClearsNativeAuthCookies(t, exchangeRes); + const tokenRes = await exchangeSession(app, exchangeRes.body.exchangeCode); + t.truthy(tokenRes.body.token); + t.truthy(tokenRes.body.expiresAt); - const cookieHeader = exchangedCookies.map(c => c.split(';')[0]).join('; '); const sessionRes = await supertest(app.getHttpServer()) .get('/api/auth/session') - .set('Cookie', cookieHeader) + .set('Authorization', `Bearer ${tokenRes.body.token}`) .expect(200); t.is(sessionRes.body.user?.id, u1.id); @@ -379,6 +723,35 @@ test('should not throw on parse of a bad cookie', async t => { t.is(req.cookies?.[badCookieKey], badCookieVal); }); +test('should only read string request cookies', t => { + const req = { + headers: {}, + cookies: { + empty: '', + list: ['session'], + object: { value: 'session' }, + session: 'valid_session', + }, + } as unknown as IncomingMessage & { cookies?: Record }; + + t.is(getRequestCookie(req, 'session'), 'valid_session'); + t.is(getRequestCookie(req, 'empty'), undefined); + t.is(getRequestCookie(req, 'list'), undefined); + t.is(getRequestCookie(req, 'object'), undefined); +}); + +test('should only read string request headers', t => { + const req = { + headers: { + 'x-list': ['value'], + 'x-string': 'value', + }, + } as unknown as IncomingMessage; + + t.is(getRequestHeader(req, 'x-string'), 'value'); + t.is(getRequestHeader(req, 'x-list'), undefined); +}); + // multiple accounts session tests test('should be able to sign in another account in one session', async t => { const { app } = t.context; @@ -400,15 +773,6 @@ test('should be able to sign in another account in one session', async t => { .send({ email: u2.email, password: u2.password }) .expect(200); - // list [u1, u2] - const sessions = await app.GET('/api/auth/sessions').expect(200); - - t.is(sessions.body.users.length, 2); - t.like( - sessions.body.users.map((u: any) => u.id), - [u1.id, u2.id] - ); - // default to latest signed in user: u2 let session = await app.GET('/api/auth/session').expect(200); diff --git a/packages/backend/server/src/__tests__/auth/email-domain.spec.ts b/packages/backend/server/src/__tests__/auth/email-domain.spec.ts new file mode 100644 index 0000000000..9b2bb47a9e --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/email-domain.spec.ts @@ -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); +}); diff --git a/packages/backend/server/src/__tests__/auth/guard.spec.ts b/packages/backend/server/src/__tests__/auth/guard.spec.ts index 9011914bcc..704f591034 100644 --- a/packages/backend/server/src/__tests__/auth/guard.spec.ts +++ b/packages/backend/server/src/__tests__/auth/guard.spec.ts @@ -5,7 +5,13 @@ import Sinon from 'sinon'; import request from 'supertest'; import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base'; -import { AuthModule, CurrentUser, Public, Session } from '../../core/auth'; +import { + AuthModule, + CurrentUser, + JwtSessionService, + Public, + Session, +} from '../../core/auth'; import { AuthService } from '../../core/auth/service'; import { Models } from '../../models'; import { createTestingApp, TestingApp } from '../utils'; @@ -37,6 +43,7 @@ const test = ava as TestFn<{ app: TestingApp; server: any; auth: AuthService; + jwtSession: JwtSessionService; models: Models; db: PrismaClient; config: ConfigFactory; @@ -53,6 +60,7 @@ test.before(async t => { t.context.app = app; t.context.server = app.getHttpServer(); t.context.auth = app.get(AuthService); + t.context.jwtSession = app.get(JwtSessionService); t.context.models = app.get(Models); t.context.db = app.get(PrismaClient); t.context.config = app.get(ConfigFactory); @@ -110,7 +118,7 @@ test('should not be able to visit private api if not signed in', async t => { t.assert(true); }); -test('should be able to visit private api if signed in', async t => { +test('should be able to visit private api with cookie session', async t => { const res = await request(t.context.server) .get('/private') .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) @@ -119,16 +127,115 @@ test('should be able to visit private api if signed in', async t => { t.is(res.body.user.id, t.context.u1.id); }); -test('should be able to visit private api with access token', async t => { - const models = t.context.app.get(Models); - const token = await models.accessToken.create({ +test('should be able to visit private api with legacy bearer session id', async t => { + const res = await request(t.context.server) + .get('/private') + .set('Authorization', `Bearer ${t.context.sessionId}`) + .expect(HttpStatus.OK); + + t.is(res.body.user.id, t.context.u1.id); +}); + +test('should be able to visit private api with personal access token', async t => { + const accessToken = await t.context.models.accessToken.create({ userId: t.context.u1.id, name: 'test', }); const res = await request(t.context.server) .get('/private') - .set('Authorization', `Bearer ${token.token}`) + .set('Authorization', `Bearer ${accessToken.token}`) + .expect(HttpStatus.OK); + + t.is(res.body.user.id, t.context.u1.id); +}); + +test('should be able to visit private api with jwt session', async t => { + const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId); + + const res = await request(t.context.server) + .get('/private') + .set('Authorization', `Bearer ${jwt.token}`) + .expect(HttpStatus.OK); + + t.is(res.body.user.id, t.context.u1.id); +}); + +test('should prefer bearer jwt over cookie session', async t => { + const u2 = await t.context.auth.signUp('u2@affine.pro', '1'); + const u2Session = await t.context.auth.createUserSession(u2.id); + const jwt = t.context.jwtSession.sign(u2.id, u2Session.sessionId); + + const res = await request(t.context.server) + .get('/private') + .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) + .set('Authorization', `Bearer ${jwt.token}`) + .expect(HttpStatus.OK); + + t.is(res.body.user.id, u2.id); +}); + +test('should reject jwt after its user session is deleted', async t => { + const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId); + + await t.context.auth.signOut(t.context.sessionId, t.context.u1.id); + + await request(t.context.server) + .get('/private') + .set('Authorization', `Bearer ${jwt.token}`) + .expect(HttpStatus.UNAUTHORIZED); + + t.pass(); +}); + +test('should enforce client version for jwt and bearer session id auth', async t => { + t.context.config.override({ + client: { + versionControl: { + enabled: true, + requiredVersion: '>=0.25.0', + }, + }, + }); + + const cases = [ + { + name: 'jwt', + token: async () => { + const session = await t.context.auth.createUserSession(t.context.u1.id); + return t.context.jwtSession.sign(t.context.u1.id, session.sessionId) + .token; + }, + }, + { + name: 'bearer session id', + token: async () => { + const session = await t.context.auth.createUserSession(t.context.u1.id); + return session.sessionId; + }, + }, + ]; + + for (const testCase of cases) { + const res = await request(t.context.server) + .get('/private') + .set('Authorization', `Bearer ${await testCase.token()}`) + .set('x-affine-version', '0.24.0') + .expect(HttpStatus.FORBIDDEN); + + t.is( + res.body.message, + 'Unsupported client with version [0.24.0], required version is [>=0.25.0].', + testCase.name + ); + } +}); + +test('should fall back to cookie session on public api when jwt is invalid', async t => { + const res = await request(t.context.server) + .get('/public') + .set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`) + .set('Authorization', 'Bearer invalid.jwt.token') .expect(HttpStatus.OK); t.is(res.body.user.id, t.context.u1.id); diff --git a/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts b/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts new file mode 100644 index 0000000000..627f6774a4 --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts @@ -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.', + }); +}); diff --git a/packages/backend/server/src/__tests__/auth/methods.spec.ts b/packages/backend/server/src/__tests__/auth/methods.spec.ts new file mode 100644 index 0000000000..2607095a42 --- /dev/null +++ b/packages/backend/server/src/__tests__/auth/methods.spec.ts @@ -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 }); +}); diff --git a/packages/backend/server/src/__tests__/cache.spec.ts b/packages/backend/server/src/__tests__/cache.spec.ts index 56133c235a..42e03c1a6b 100644 --- a/packages/backend/server/src/__tests__/cache.spec.ts +++ b/packages/backend/server/src/__tests__/cache.spec.ts @@ -50,6 +50,13 @@ test('should be able to set cache with ttl', async t => { t.true(ttl > 0); }); +test('should reject invalid ttl options', async t => { + t.false(await cache.set(key('test-invalid-ttl'), 1, { ttl: 0 })); + t.is(await cache.get(key('test-invalid-ttl')), undefined); + t.false(await cache.setnx(key('test-invalid-ttl-nx'), 1, { ttl: 0 })); + t.is(await cache.get(key('test-invalid-ttl-nx')), undefined); +}); + test('should be able to incr/decr number cache', async t => { t.true(await cache.set(key('test-incr'), 1)); t.is(await cache.increase(key('test-incr')), 2); diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 1bd25a1283..4d6e157599 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -63,6 +63,14 @@ export class TestingApp extends NestApplication { await this.close(); } + clearAuth() { + this.resetRateLimit(); + this.sessionCookie = null; + this.currentUserCookie = null; + this.csrfCookie = null; + this.userCookies.clear(); + } + request( method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch', path: string diff --git a/packages/backend/server/src/__tests__/e2e/user/user-by-email-security.spec.ts b/packages/backend/server/src/__tests__/e2e/user/user-by-email-security.spec.ts index 768df24aac..7e5e5d5681 100644 --- a/packages/backend/server/src/__tests__/e2e/user/user-by-email-security.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/user/user-by-email-security.spec.ts @@ -7,7 +7,7 @@ import { app, e2e, Mockers } from '../test'; e2e('user(email) should return null without auth', async t => { const user = await app.create(Mockers.User); - await app.logout(); + app.clearAuth(); const res = await app.gql({ query: getUserQuery, @@ -18,7 +18,7 @@ e2e('user(email) should return null without auth', async t => { }); e2e('user(email) should return null outside workspace scope', async t => { - await app.logout(); + app.clearAuth(); const me = await app.signup(); const other = await app.create(Mockers.User); @@ -43,7 +43,7 @@ e2e('user(email) should return null outside workspace scope', async t => { }); e2e('user(email) should return user within workspace scope', async t => { - await app.logout(); + app.clearAuth(); const me = await app.signup(); const other = await app.create(Mockers.User); const ws = await app.create(Mockers.Workspace, { owner: me }); @@ -67,7 +67,7 @@ e2e('user(email) should return user within workspace scope', async t => { }); e2e('user(email) should be rate limited', async t => { - await app.logout(); + app.clearAuth(); const me = await app.signup(); const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({ diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index bbc573389a..28cf546946 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -7,6 +7,7 @@ import Sinon from 'sinon'; import { AppModule } from '../../app.module'; import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base'; +import { SessionCache } from '../../base/cache'; import { ConfigModule } from '../../base/config'; import { CurrentUser } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; @@ -23,6 +24,7 @@ import { createTestingApp, currentUser, TestingApp } from '../utils'; const test = ava as TestFn<{ auth: AuthService; oauth: OAuthService; + cache: SessionCache; models: Models; u1: CurrentUser; db: PrismaClient; @@ -62,6 +64,7 @@ test.before(async t => { t.context.auth = app.get(AuthService); t.context.oauth = app.get(OAuthService); + t.context.cache = app.get(SessionCache); t.context.models = app.get(Models); t.context.db = app.get(PrismaClient); t.context.app = app; @@ -244,6 +247,7 @@ test('should forbid preflight with untrusted redirect_uri', async t => { test('should throw if client_nonce is missing in preflight', async t => { const { app } = t.context; + app.clearAuth(); await app .POST('/api/oauth/preflight') @@ -293,6 +297,19 @@ test('should be able to save oauth state', async t => { t.is(state!.provider, OAuthProviderName.Google); }); +test('should save oauth state with three hour ttl', async t => { + const { cache, oauth } = t.context; + + const id = await oauth.saveOAuthState({ + provider: OAuthProviderName.Google, + }); + + const ttl = await cache.ttl(`auth_challenge:oauth_state:${id}`); + + t.true(ttl > 2 * 3600); + t.true(ttl <= 3 * 3600); +}); + test('should be able to get registered oauth providers', async t => { const { oauth } = t.context; @@ -550,12 +567,40 @@ test('should be able to sign up with oauth', async t => { const clientNonce = mockOAuthProvider(app, 'u2@affine.pro'); - await app + const res = await app .POST('/api/oauth/callback') + .set('x-affine-client-kind', 'native') .send({ code: '1', state: '1', client_nonce: clientNonce }) .expect(HttpStatus.OK); - const sessionUser = await currentUser(app); + t.truthy(res.body.exchangeCode); + const tokenRes = await app + .POST('/api/auth/native/exchange') + .set('x-affine-client-kind', 'native') + .send({ code: res.body.exchangeCode }) + .expect(201); + t.truthy(tokenRes.body.token); + t.truthy(tokenRes.body.expiresAt); + const setCookies = res.get('Set-Cookie') ?? []; + for (const name of [ + AuthService.sessionCookieName, + AuthService.userCookieName, + AuthService.csrfCookieName, + ]) { + t.true( + setCookies.some( + cookie => + cookie.startsWith(`${name}=;`) && + /Expires=Thu, 01 Jan 1970/i.test(cookie) + ) + ); + } + + const sessionUserRes = await app + .GET('/api/auth/session') + .set('Authorization', `Bearer ${tokenRes.body.token}`) + .expect(200); + const sessionUser = sessionUserRes.body.user; t.truthy(sessionUser); t.is(sessionUser!.email, 'u2@affine.pro'); diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index f7a3844178..c81fd0e0e7 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -60,14 +60,17 @@ async function withTimeout( } } -function createClient(url: string, cookie: string): SocketIOClient { +function createClient( + url: string, + cookie?: string, + auth?: Record +): SocketIOClient { return io(url, { transports: ['websocket'], reconnection: false, forceNew: true, - extraHeaders: { - cookie, - }, + ...(cookie ? { extraHeaders: { cookie } } : {}), + ...(auth ? { auth } : {}), }); } @@ -146,14 +149,24 @@ function expectNoEvent( async function login(app: TestingApp) { const user = await app.createUser(); - const res = await app + const cookieRes = await app .POST('/api/auth/sign-in') .send({ email: user.email, password: user.password }) .expect(200); + const nativeRes = await app + .POST('/api/auth/sign-in') + .set('x-affine-client-kind', 'native') + .send({ email: user.email, password: user.password }) + .expect(200); + const tokenRes = await app + .POST('/api/auth/native/exchange') + .set('x-affine-client-kind', 'native') + .send({ code: nativeRes.body.exchangeCode }) + .expect(201); - const cookies = res.get('Set-Cookie') ?? []; + const cookies = cookieRes.get('Set-Cookie') ?? []; const cookieHeader = cookies.map(c => c.split(';')[0]).join('; '); - return { user, cookieHeader }; + return { user, cookieHeader, token: tokenRes.body.token as string }; } function createYjsUpdateBase64() { @@ -217,6 +230,52 @@ test.after.always(async () => { await app.close(); }); +test('should reject websocket legacy session token auth', async t => { + const { cookieHeader } = await login(app); + const sessionCookie = cookieHeader + .split('; ') + .find(cookie => cookie.startsWith('affine_session=')); + const token = sessionCookie?.split('=')[1]; + t.truthy(token); + + const socket = createClient(url, undefined, { token }); + + try { + await t.throwsAsync(() => waitForConnect(socket)); + } finally { + socket.disconnect(); + } +}); + +test('should connect websocket with jwt auth', async t => { + const { token } = await login(app); + const socket = createClient(url, undefined, { token, tokenType: 'jwt' }); + + try { + await waitForConnect(socket); + t.true(socket.connected); + } finally { + socket.disconnect(); + } +}); + +test('should reject websocket jwt auth after session deletion', async t => { + const { token } = await login(app); + + await app + .POST('/api/auth/sign-out') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + const socket = createClient(url, undefined, { token, tokenType: 'jwt' }); + + try { + await t.throwsAsync(() => waitForConnect(socket)); + } finally { + socket.disconnect(); + } +}); + test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', async t => { const { user, cookieHeader } = await login(app); const spaceId = user.id; diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index d6c88cb0e4..8a87b3a096 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -110,6 +110,10 @@ export class TestingApp extends ApplyType() { async initTestingDB() { await initTestingDB(this); + this.clearAuth(); + } + + clearAuth() { this.sessionCookie = null; this.currentUserCookie = null; this.csrfCookie = null; diff --git a/packages/backend/server/src/base/cache/provider.ts b/packages/backend/server/src/base/cache/provider.ts index 9ef866adf5..4953d43167 100644 --- a/packages/backend/server/src/base/cache/provider.ts +++ b/packages/backend/server/src/base/cache/provider.ts @@ -7,6 +7,14 @@ export interface CacheSetOptions { ttl?: number; } +const GET_AND_DELETE_LUA = ` +local value = redis.call("GET", KEYS[1]) +if value then + redis.call("DEL", KEYS[1]) +end +return value +`; + export function isValidCacheTtl(ttl: unknown): ttl is number { return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0; } @@ -32,12 +40,15 @@ export class CacheProvider { value: T, opts: CacheSetOptions = {} ): Promise { - if (opts.ttl) { + if (isValidCacheTtl(opts.ttl)) { return this.redis .set(key, JSON.stringify(value), 'PX', opts.ttl) .then(() => true) .catch(() => false); } + if (opts.ttl !== undefined) { + return false; + } return this.redis .set(key, JSON.stringify(value)) @@ -58,12 +69,15 @@ export class CacheProvider { value: T, opts: CacheSetOptions = {} ): Promise { - if (opts.ttl) { + if (isValidCacheTtl(opts.ttl)) { return this.redis .set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX') .then(v => !!v) .catch(() => false); } + if (opts.ttl !== undefined) { + return false; + } return this.redis .set(key, JSON.stringify(value), 'NX') @@ -78,6 +92,13 @@ export class CacheProvider { .catch(() => false); } + async getAndDelete(key: string): Promise { + 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 { return this.redis .exists(key) diff --git a/packages/backend/server/src/base/utils/request.ts b/packages/backend/server/src/base/utils/request.ts index e9ac6a4c2c..c7cd641ede 100644 --- a/packages/backend/server/src/base/utils/request.ts +++ b/packages/backend/server/src/base/utils/request.ts @@ -7,12 +7,16 @@ import { GqlArgumentsHost } from '@nestjs/graphql'; import type { Request, Response } from 'express'; import { ClsServiceManager } from 'nestjs-cls'; import type { Socket } from 'socket.io'; +import { z } from 'zod'; type RequestResponse = { req: Request; res?: Response; }; +const RequestCookieValueSchema = z.string().min(1); +const RequestHeaderValueSchema = z.string().min(1); + export function getRequestResponseFromHost( host: ArgumentsHost ): RequestResponse { @@ -68,9 +72,7 @@ export function getRequestResponseFromContext( export function parseCookies( req: IncomingMessage & { cookies?: Record } ) { - if (req.cookies) { - return; - } + if (req.cookies) return; const cookieStr = req.headers.cookie ?? ''; req.cookies = cookieStr.split(';').reduce( @@ -103,6 +105,25 @@ export function parseCookies( ); } +export function getRequestCookie( + req: IncomingMessage & { cookies?: Record }, + name: string +) { + parseCookies(req as IncomingMessage & { cookies?: Record }); + + 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 * diff --git a/packages/backend/server/src/core/auth/challenge-store.ts b/packages/backend/server/src/core/auth/challenge-store.ts new file mode 100644 index 0000000000..5cf65f9106 --- /dev/null +++ b/packages/backend/server/src/core/auth/challenge-store.ts @@ -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( + purpose: AuthChallengePurpose, + payload: T | ((token: string) => T), + ttlMs: number + ): Promise { + 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(purpose: AuthChallengePurpose, token: string) { + return (await this.cache.get(this.key(purpose, token))) ?? null; + } + + async consume(purpose: AuthChallengePurpose, token: string) { + return (await this.cache.getAndDelete(this.key(purpose, token))) ?? null; + } + + private key(purpose: AuthChallengePurpose, token: string) { + return `auth_challenge:${purpose}:${token}`; + } +} diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index af8940d7d2..cadc45fcfa 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -1,4 +1,4 @@ -import { resolveMx, resolveTxt, setServers } from 'node:dns/promises'; +import { setServers } from 'node:dns/promises'; import { Body, @@ -6,7 +6,6 @@ import { Get, Header, HttpStatus, - Logger, Post, Query, Req, @@ -16,27 +15,33 @@ import type { Request, Response } from 'express'; import { ActionForbidden, - Config, - CryptoHelper, EmailTokenNotFound, + getRequestCookie, InvalidAuthState, InvalidEmail, - InvalidEmailToken, - SignUpForbidden, Throttle, - URLHelper, UseNamedGuard, WrongSignInCredentials, } from '../../base'; -import { Models, TokenType } from '../../models'; +import { Models } from '../../models'; import { validators } from '../utils/validators'; import { Public } from './guard'; -import { AuthService } from './service'; +import { MagicLinkAuthService } from './magic-link'; +import { AuthMethodsService } from './methods'; +import { SessionExchangeService } from './native-exchange'; +import { OpenAppAuthService } from './open-app'; +import { AuthService, sessionUser } from './service'; import { CurrentUser, Session } from './session'; +import { SessionIssuer } from './session-issuer'; interface PreflightResponse { registered: boolean; - hasPassword: boolean; + methods: { + password: { available: boolean }; + magicLink: { available: boolean }; + oauth: { available: boolean; providers: string[] }; + passkey: { available: boolean; discoverable: boolean }; + }; } interface SignInCredential { @@ -56,17 +61,25 @@ interface OpenAppSignInCredential { code: string; } +interface NativeSessionExchangeCredential { + code: string; +} + +type SignInResponse = CurrentUser & { + exchangeCode?: string; +}; + @Throttle('strict') @Controller('/api/auth') export class AuthController { - private readonly logger = new Logger(AuthController.name); - constructor( - private readonly url: URLHelper, private readonly auth: AuthService, - private readonly models: Models, - private readonly config: Config, - private readonly crypto: CryptoHelper + private readonly sessionIssuer: SessionIssuer, + private readonly magicLink: MagicLinkAuthService, + private readonly openApp: OpenAppAuthService, + private readonly authMethods: AuthMethodsService, + private readonly sessionExchange: SessionExchangeService, + private readonly models: Models ) { if (env.dev) { // set DNS servers in dev mode @@ -89,19 +102,13 @@ export class AuthController { } validators.assertValidEmail(params.email); - const user = await this.models.user.getUserByEmail(params.email); + return this.authMethods.loginPreflight(params.email); + } - if (!user) { - return { - registered: false, - hasPassword: false, - }; - } - - return { - registered: user.registered, - hasPassword: !!user.password, - }; + @UseNamedGuard('version') + @Get('/methods') + async boundMethods(@CurrentUser() user: CurrentUser) { + return this.authMethods.boundMethods(user.id); } @Public() @@ -142,10 +149,17 @@ export class AuthController { email: string, password: string ) { - const user = await this.auth.signIn(email, password); + const identity = await this.auth.verifyPassword(email, password); - await this.auth.setCookies(req, res, user.id); - res.status(HttpStatus.OK).send(user); + const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); + const user = await this.models.user.get(identity.userId); + if (!user) { + throw new WrongSignInCredentials({ email }); + } + res.status(HttpStatus.OK).send({ + ...sessionUser(user), + exchangeCode, + } satisfies SignInResponse); } async sendMagicLink( @@ -154,105 +168,10 @@ export class AuthController { callbackUrl = '/magic-link', clientNonce?: string ) { - if (!this.url.isAllowedCallbackUrl(callbackUrl)) { - throw new ActionForbidden(); - } - - const callbackUrlObj = this.url.url(callbackUrl); - const redirectUriInCallback = - callbackUrlObj.searchParams.get('redirect_uri'); - if ( - redirectUriInCallback && - !this.url.isAllowedRedirectUri(redirectUriInCallback) - ) { - throw new ActionForbidden(); - } - - // send email magic link - const user = await this.models.user.getUserByEmail(email, { - withDisabled: true, - }); - - if (!user) { - if (!this.config.auth.allowSignup) { - throw new SignUpForbidden(); - } - - if (this.config.auth.requireEmailDomainVerification) { - // verify domain has MX, SPF, DMARC records - const [name, domain, ...rest] = email.split('@'); - if (rest.length || !domain) { - throw new InvalidEmail({ email }); - } - const [mx, spf, dmarc] = await Promise.allSettled([ - resolveMx(domain).then(t => t.map(mx => mx.exchange).filter(Boolean)), - resolveTxt(domain).then(t => - t.map(([k]) => k).filter(txt => txt.includes('v=spf1')) - ), - resolveTxt('_dmarc.' + domain).then(t => - t.map(([k]) => k).filter(txt => txt.includes('v=DMARC1')) - ), - ]).then(t => t.filter(t => t.status === 'fulfilled').map(t => t.value)); - if (!mx?.length || !spf?.length || !dmarc?.length) { - throw new InvalidEmail({ email }); - } - // filter out alias emails - if (name.includes('+')) { - throw new InvalidEmail({ email }); - } - } - } else if (user.disabled) { - throw new WrongSignInCredentials({ email }); - } - - const ttlInSec = 30 * 60; - const token = await this.models.verificationToken.create( - TokenType.SignIn, - email, - ttlInSec - ); - - const otp = this.crypto.otp(); - await this.models.magicLinkOtp.upsert(email, otp, token, clientNonce); - - const magicLink = this.url.link(callbackUrl, { token: otp, email }); - if (env.dev) { - // make it easier to test in dev mode - this.logger.debug(`Magic link: ${magicLink}`); - } - - await this.auth.sendSignInEmail(email, magicLink, otp, !user); - - res.status(HttpStatus.OK).send({ - email: email, - }); + const payload = await this.magicLink.send(email, callbackUrl, clientNonce); + res.status(HttpStatus.OK).send(payload); } - @Public() - /** - * @deprecated Kept for 0.25 clients that still call GET `/api/auth/sign-out`. - * Use POST `/api/auth/sign-out` instead. - */ - @Get('/sign-out') - async signOutDeprecated( - @Res() res: Response, - @Session() session: Session | undefined, - @Query('user_id') userId: string | undefined - ) { - res.setHeader('Deprecation', 'true'); - - if (!session) { - res.status(HttpStatus.OK).send({}); - return; - } - - await this.auth.signOut(session.sessionId, userId); - await this.auth.refreshCookies(res, session.sessionId); - - res.status(HttpStatus.OK).send({}); - } - - @Public() @Post('/sign-out') async signOut( @Req() req: Request, @@ -265,14 +184,15 @@ export class AuthController { return; } - const csrfCookie = req.cookies?.[AuthService.csrfCookieName] as - | string - | undefined; + if (req.authType === 'jwt') { + await this.auth.signOut(session.sessionId, session.user.id); + res.status(HttpStatus.OK).send({}); + return; + } + + const csrfCookie = getRequestCookie(req, AuthService.csrfCookieName); const csrfHeader = req.get('x-affine-csrf-token'); - if ( - csrfHeader && // optional for backward compatibility, drop after 0.25.0 outdated - (!csrfCookie || csrfCookie !== csrfHeader) - ) { + if (!csrfHeader || !csrfCookie || csrfCookie !== csrfHeader) { throw new ActionForbidden(); } @@ -286,17 +206,8 @@ export class AuthController { @UseNamedGuard('version') @Post('/open-app/sign-in-code') async openAppSignInCode(@CurrentUser() user?: CurrentUser) { - if (!user) { - throw new ActionForbidden(); - } - - // short-lived one-time code for handing off the authenticated session - const code = await this.models.verificationToken.create( - TokenType.OpenAppSignIn, - user.id, - 5 * 60 - ); - + if (!user) throw new ActionForbidden(); + const code = await this.openApp.createSignInCode(user); return { code }; } @@ -308,21 +219,21 @@ export class AuthController { @Res() res: Response, @Body() credential: OpenAppSignInCredential ) { - if (!credential?.code) { - throw new InvalidAuthState(); - } + if (!credential?.code) throw new InvalidAuthState(); + const identity = await this.openApp.verifySignInCode(credential.code); + const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); + res.send({ id: identity.userId, exchangeCode }); + } - const tokenRecord = await this.models.verificationToken.get( - TokenType.OpenAppSignIn, - credential.code - ); - - if (!tokenRecord?.credential) { - throw new InvalidAuthState(); - } - - await this.auth.setCookies(req, res, tokenRecord.credential); - res.send({ id: tokenRecord.credential }); + @Public() + @UseNamedGuard('version') + @Post('/native/exchange') + async exchangeSession( + @Req() req: Request, + @Body() credential: NativeSessionExchangeCredential + ) { + if (!credential?.code) throw new InvalidAuthState(); + return await this.sessionExchange.exchange(req, credential.code); } @Public() @@ -334,42 +245,11 @@ export class AuthController { @Body() { email, token: otp, client_nonce: clientNonce }: MagicLinkCredential ) { - if (!otp || !email) { - throw new EmailTokenNotFound(); - } - + if (!otp || !email) throw new EmailTokenNotFound(); validators.assertValidEmail(email); - - const consumed = await this.models.magicLinkOtp.consume( - email, - otp, - clientNonce - ); - if (!consumed.ok) { - if (consumed.reason === 'nonce_mismatch') { - throw new InvalidAuthState(); - } - throw new InvalidEmailToken(); - } - - const token = consumed.token; - - const tokenRecord = await this.models.verificationToken.verify( - TokenType.SignIn, - token, - { - credential: email, - } - ); - - if (!tokenRecord) { - throw new InvalidEmailToken(); - } - - const user = await this.models.user.fulfill(email); - - await this.auth.setCookies(req, res, user.id); - res.send({ id: user.id }); + const identity = await this.magicLink.verify(email, otp, clientNonce); + const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); + res.send({ id: identity.userId, exchangeCode }); } @UseNamedGuard('version') @@ -377,24 +257,6 @@ export class AuthController { @Public() @Get('/session') async currentSessionUser(@CurrentUser() user?: CurrentUser) { - return { - user, - }; - } - - @Throttle('default', { limit: 1200 }) - @Public() - @Get('/sessions') - async currentSessionUsers(@Req() req: Request) { - const token = req.cookies[AuthService.sessionCookieName]; - if (!token) { - return { - users: [], - }; - } - - return { - users: await this.auth.getUserList(token), - }; + return { user }; } } diff --git a/packages/backend/server/src/core/auth/email-domain.ts b/packages/backend/server/src/core/auth/email-domain.ts new file mode 100644 index 0000000000..df140fa240 --- /dev/null +++ b/packages/backend/server/src/core/auth/email-domain.ts @@ -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(promise: Promise, timeoutMs: number) { + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, 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; +} diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 287537af27..1ab9025b44 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -23,6 +23,12 @@ import { UnsupportedClientVersion, } from '../../base'; import { WEBSOCKET_OPTIONS } from '../../base/websocket'; +import { + extractTokenFromHeader, + getSessionOptionsFromRequest, + SessionIdSchema, +} from './input'; +import { isLikelyJwt, JwtSessionService } from './jwt-session'; import { AuthService } from './service'; import { Session, TokenSession } from './session'; @@ -31,9 +37,16 @@ const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal'); const INTERNAL_ACCESS_TOKEN_TTL_MS = 5 * 60 * 1000; const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000; +type AuthenticatedRequestSession = + | { type: 'jwt'; session: Session } + | { type: 'cookie_session'; session: Session } + | { type: 'legacy_bearer_session'; session: Session } + | { type: 'access_token'; token: TokenSession }; + @Injectable() export class AuthGuard implements CanActivate, OnModuleInit { private auth!: AuthService; + private jwtSession!: JwtSessionService; private readonly cachedVersionRange = new Map(); private static readonly HARD_REQUIRED_VERSION = '>=0.25.0'; private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)'; @@ -48,6 +61,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { onModuleInit() { this.auth = this.ref.get(AuthService, { strict: false }); + this.jwtSession = this.ref.get(JwtSessionService, { strict: false }); } async canActivate(context: ExecutionContext) { @@ -110,12 +124,102 @@ export class AuthGuard implements CanActivate, OnModuleInit { res?: Response, isPublic = false ): Promise { - const userSession = await this.signInWithCookie(req, res, isPublic); - if (userSession) { - return userSession; + const result = await this.resolveRequestSession(req, res, isPublic); + return result?.type === 'access_token' + ? result.token + : (result?.session ?? null); + } + + private async resolveRequestSession( + req: Request, + res?: Response, + isPublic = false + ): Promise { + 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 { + 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 { + if (req.session && req.session.sessionId === sessionId) return req.session; + const parsedSessionId = SessionIdSchema.safeParse(sessionId); + if (!parsedSessionId.success) return null; + + const { userId } = getSessionOptionsFromRequest(req); + const userSession = await this.auth.getUserSession( + parsedSessionId.data, + userId + ); + + if (!userSession) return null; + req.session = { ...userSession.session, user: userSession.user }; + const versionAllowed = await this.checkUserSessionClientVersion( + req, + req.session, + res, + isPublic + ); + if (!versionAllowed) { + req.session = undefined; + return null; + } + req.authType = 'session'; + + return req.session; } async signInWithCookie( @@ -123,37 +227,24 @@ export class AuthGuard implements CanActivate, OnModuleInit { res?: Response, isPublic = false ): Promise { - if (req.session) { - return req.session; - } + if (req.session) return req.session; // TODO(@forehalo): a cache for user session const userSession = await this.auth.getUserSessionFromRequest(req, res); if (userSession) { const headerClientVersion = getClientVersionFromRequest(req); - if (this.config.client.versionControl.enabled) { - const clientVersion = - headerClientVersion ?? - userSession.session.refreshClientVersion ?? - userSession.session.signInClientVersion; + req.session = { ...userSession.session, user: userSession.user }; - const versionCheckResult = this.checkClientVersion(clientVersion); - if (!versionCheckResult.ok) { - await this.auth.signOut(userSession.session.sessionId); - if (res) { - await this.auth.refreshCookies(res, userSession.session.sessionId); - } - - if (isPublic) { - return null; - } - - throw new UnsupportedClientVersion({ - clientVersion: clientVersion ?? 'unset_or_invalid', - requiredVersion: versionCheckResult.requiredVersion, - }); - } + const versionAllowed = await this.checkUserSessionClientVersion( + req, + req.session, + res, + isPublic + ); + if (!versionAllowed) { + req.session = undefined; + return null; } if (res) { @@ -165,10 +256,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { ); } - req.session = { - ...userSession.session, - user: userSession.user, - }; + req.authType = 'session'; return req.session; } @@ -176,6 +264,42 @@ export class AuthGuard implements CanActivate, OnModuleInit { return null; } + private async checkUserSessionClientVersion( + req: Request, + session: Session, + res?: Response, + isPublic = false + ) { + if (!this.config.client.versionControl.enabled) { + return true; + } + + const headerClientVersion = getClientVersionFromRequest(req); + const clientVersion = + headerClientVersion ?? + session.refreshClientVersion ?? + session.signInClientVersion; + + const versionCheckResult = this.checkClientVersion(clientVersion); + if (versionCheckResult.ok) { + return true; + } + + await this.auth.signOut(session.sessionId); + if (res) { + await this.auth.refreshCookies(res, session.sessionId); + } + + if (isPublic) { + return false; + } + + throw new UnsupportedClientVersion({ + clientVersion: clientVersion ?? 'unset_or_invalid', + requiredVersion: versionCheckResult.requiredVersion, + }); + } + async signInWithAccessToken(req: Request): Promise { if (req.token) { return req.token; @@ -184,10 +308,8 @@ export class AuthGuard implements CanActivate, OnModuleInit { const tokenSession = await this.auth.getTokenSessionFromRequest(req); if (tokenSession) { - req.token = { - ...tokenSession.token, - user: tokenSession.user, - }; + req.token = { ...tokenSession.token, user: tokenSession.user }; + req.authType = 'access_token'; return req.token; } @@ -280,11 +402,9 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = { // compatibility with websocket request parseCookies(upgradeReq); - upgradeReq.cookies = { - [AuthService.sessionCookieName]: handshake.auth.token, - [AuthService.userCookieName]: handshake.auth.userId, - ...upgradeReq.cookies, - }; + if (handshake.auth.tokenType === 'jwt') { + upgradeReq.headers.authorization = `Bearer ${handshake.auth.token}`; + } const session = await (async () => { try { diff --git a/packages/backend/server/src/core/auth/identity.ts b/packages/backend/server/src/core/auth/identity.ts new file mode 100644 index 0000000000..716c1ffd9d --- /dev/null +++ b/packages/backend/server/src/core/auth/identity.ts @@ -0,0 +1,12 @@ +export type AuthMethod = + | 'password' + | 'magic_link' + | 'oauth' + | 'open_app' + | 'passkey'; + +export interface VerifiedIdentity { + userId: string; + method: AuthMethod; + clientVersion?: string; +} diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index 506bf79eea..f10c304bf2 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -6,11 +6,18 @@ import { FeatureModule } from '../features'; import { MailModule } from '../mail'; import { QuotaModule } from '../quota'; import { UserModule } from '../user'; +import { AuthChallengeStore } from './challenge-store'; import { AuthController } from './controller'; import { AuthGuard, AuthWebsocketOptionsProvider } from './guard'; import { AuthCronJob } from './job'; +import { JwtSessionService } from './jwt-session'; +import { MagicLinkAuthService } from './magic-link'; +import { AuthMethodsService } from './methods'; +import { SessionExchangeService } from './native-exchange'; +import { OpenAppAuthService } from './open-app'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; +import { SessionIssuer } from './session-issuer'; @Module({ imports: [FeatureModule, UserModule, QuotaModule, MailModule], @@ -18,15 +25,40 @@ import { AuthService } from './service'; AuthService, AuthResolver, AuthGuard, + JwtSessionService, + SessionIssuer, + AuthChallengeStore, + MagicLinkAuthService, + OpenAppAuthService, + AuthMethodsService, + SessionExchangeService, AuthCronJob, AuthWebsocketOptionsProvider, ], - exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider], + exports: [ + AuthService, + AuthGuard, + JwtSessionService, + SessionIssuer, + AuthChallengeStore, + MagicLinkAuthService, + OpenAppAuthService, + AuthMethodsService, + SessionExchangeService, + AuthWebsocketOptionsProvider, + ], controllers: [AuthController], }) export class AuthModule {} +export { AuthChallengeStore } from './challenge-store'; export * from './guard'; +export * from './identity'; +export * from './input'; +export { MagicLinkAuthService } from './magic-link'; +export * from './methods'; +export { SessionExchangeService }; +export { OpenAppAuthService } from './open-app'; export { ClientTokenType } from './resolver'; -export { AuthService }; +export { AuthService, JwtSessionService, SessionIssuer }; export * from './session'; diff --git a/packages/backend/server/src/core/auth/input.ts b/packages/backend/server/src/core/auth/input.ts new file mode 100644 index 0000000000..d0108926de --- /dev/null +++ b/packages/backend/server/src/core/auth/input.ts @@ -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; +} diff --git a/packages/backend/server/src/core/auth/jwt-session.ts b/packages/backend/server/src/core/auth/jwt-session.ts new file mode 100644 index 0000000000..c8118e65c1 --- /dev/null +++ b/packages/backend/server/src/core/auth/jwt-session.ts @@ -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 { + let payload: string | JwtPayload; + try { + payload = jwt.verify(token, this.currentKey, { + algorithms: ['HS256'], + audience: JWT_SESSION_AUDIENCE, + issuer: JWT_SESSION_ISSUER, + }); + } catch { + throw new AuthenticationRequired(); + } + + if (!isUserSessionJwtPayload(payload)) throw new AuthenticationRequired(); + const userSession = await this.models.session + .findUserSessionsBySessionId(payload.sid) + .then(sessions => sessions.find(s => s.userId === payload.sub)); + if (!userSession) throw new AuthenticationRequired(); + const user = await this.models.user.get(payload.sub); + if (!user) throw new AuthenticationRequired(); + return { ...userSession, user: sessionUser(user) as CurrentUser }; + } +} + +export function isLikelyJwt(token: string) { + return token.split('.').length === 3; +} diff --git a/packages/backend/server/src/core/auth/magic-link.ts b/packages/backend/server/src/core/auth/magic-link.ts new file mode 100644 index 0000000000..2da2ec767c --- /dev/null +++ b/packages/backend/server/src/core/auth/magic-link.ts @@ -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 { + 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 }); + } + } +} diff --git a/packages/backend/server/src/core/auth/methods.ts b/packages/backend/server/src/core/auth/methods.ts new file mode 100644 index 0000000000..1d8bbdf194 --- /dev/null +++ b/packages/backend/server/src/core/auth/methods.ts @@ -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 { + 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( + AUTH_OAUTH_PROVIDER_READER, + { strict: false } + ); + return reader.providers; + } catch { + return []; + } + } +} diff --git a/packages/backend/server/src/core/auth/native-exchange.ts b/packages/backend/server/src/core/auth/native-exchange.ts new file mode 100644 index 0000000000..c7d196d029 --- /dev/null +++ b/packages/backend/server/src/core/auth/native-exchange.ts @@ -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( + 'native_session_exchange', + { userId, sessionId }, + 60 * 1000 + ); + } + + async exchange(req: Request, code: string) { + if (!isNativeClientRequest(req)) { + throw new ActionForbidden(); + } + + const payload = await this.challenges.consume( + 'native_session_exchange', + code + ); + + if (!payload?.userId || !payload.sessionId) { + throw new InvalidAuthState(); + } + + const session = await this.auth.getUserSession( + payload.sessionId, + payload.userId + ); + if (!session) { + throw new InvalidAuthState(); + } + + return this.jwtSession.sign(payload.userId, payload.sessionId); + } +} diff --git a/packages/backend/server/src/core/auth/open-app.ts b/packages/backend/server/src/core/auth/open-app.ts new file mode 100644 index 0000000000..9a66b21581 --- /dev/null +++ b/packages/backend/server/src/core/auth/open-app.ts @@ -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 { + 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' }; + } +} diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index f36e66ed9e..76eb6c6101 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -63,7 +63,7 @@ export class AuthResolver { @ResolveField(() => ClientTokenType, { name: 'token', - deprecationReason: 'use [/api/auth/sign-in?native=true] instead', + deprecationReason: 'use native session exchange instead', }) async clientToken( @CurrentUser() currentUser: CurrentUser, diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 7d9decfcca..f9a4532b16 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -4,14 +4,18 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import type { CookieOptions, Request, Response } from 'express'; import { assign, pick } from 'lodash-es'; -import { - Config, - getClientVersionFromRequest, - SignUpForbidden, -} from '../../base'; +import { Config, SignUpForbidden } from '../../base'; import { Models, type User, type UserSession } from '../../models'; import { Mailer } from '../mail/mailer'; import { createDevUsers } from './dev'; +import type { VerifiedIdentity } from './identity'; +import { + CSRF_COOKIE_NAME, + extractTokenFromHeader, + getSessionOptionsFromRequest, + SESSION_COOKIE_NAME, + USER_COOKIE_NAME, +} from './input'; import type { CurrentUser } from './session'; export function sessionUser( @@ -27,20 +31,12 @@ export function sessionUser( }); } -function extractTokenFromHeader(authorization: string) { - if (!/^Bearer\s/i.test(authorization)) { - return; - } - - return authorization.substring(7); -} - @Injectable() export class AuthService implements OnApplicationBootstrap { readonly cookieOptions: CookieOptions; - static readonly sessionCookieName = 'affine_session'; - static readonly userCookieName = 'affine_user_id'; - static readonly csrfCookieName = 'affine_csrf_token'; + static readonly sessionCookieName = SESSION_COOKIE_NAME; + static readonly userCookieName = USER_COOKIE_NAME; + static readonly csrfCookieName = CSRF_COOKIE_NAME; constructor( private readonly config: Config, @@ -90,6 +86,14 @@ export class AuthService implements OnApplicationBootstrap { return this.models.user.signIn(email, password).then(sessionUser); } + async verifyPassword( + email: string, + password: string + ): Promise { + const user = await this.models.user.signIn(email, password); + return { userId: user.id, method: 'password' }; + } + async signOut(sessionId: string, userId?: string) { // sign out all users in the session if (!userId) { @@ -104,10 +108,7 @@ export class AuthService implements OnApplicationBootstrap { userId?: string ): Promise<{ user: CurrentUser; session: UserSession } | null> { const sessions = await this.getUserSessions(sessionId); - - if (!sessions.length) { - return null; - } + if (!sessions.length) return null; let userSession: UserSession | undefined; @@ -201,55 +202,6 @@ export class AuthService implements OnApplicationBootstrap { return await this.models.session.deleteUserSessions(userId); } - getSessionOptionsFromRequest(req: Request) { - let sessionId: string | undefined = - req.cookies[AuthService.sessionCookieName]; - - if (!sessionId && req.headers.authorization) { - sessionId = extractTokenFromHeader(req.headers.authorization); - } - - const userId: string | undefined = - req.cookies[AuthService.userCookieName] || - req.headers[AuthService.userCookieName.replaceAll('_', '-')]; - - return { - sessionId, - userId, - }; - } - - async setCookies( - req: Request, - res: Response, - userId: string, - clientVersion?: string - ) { - const { sessionId } = this.getSessionOptionsFromRequest(req); - - const signInClientVersion = - clientVersion ?? getClientVersionFromRequest(req); - const userSession = await this.createUserSession( - userId, - sessionId, - undefined, - signInClientVersion - ); - - res.cookie(AuthService.sessionCookieName, userSession.sessionId, { - ...this.cookieOptions, - expires: userSession.expiresAt ?? void 0, - }); - - res.cookie(AuthService.csrfCookieName, randomUUID(), { - ...this.cookieOptions, - httpOnly: false, - expires: userSession.expiresAt ?? void 0, - }); - - this.setUserCookie(res, userId); - } - async refreshCookies(res: Response, sessionId?: string) { if (sessionId) { const users = await this.getUserList(sessionId); @@ -264,7 +216,7 @@ export class AuthService implements OnApplicationBootstrap { this.clearCookies(res); } - private clearCookies(res: Response>) { + clearCookies(res: Response>) { res.clearCookie(AuthService.sessionCookieName); res.clearCookie(AuthService.userCookieName); res.clearCookie(AuthService.csrfCookieName); @@ -281,12 +233,8 @@ export class AuthService implements OnApplicationBootstrap { } async getUserSessionFromRequest(req: Request, res?: Response) { - const { sessionId, userId } = this.getSessionOptionsFromRequest(req); - - if (!sessionId) { - return null; - } - + const { sessionId, userId } = getSessionOptionsFromRequest(req); + if (!sessionId) return null; const session = await this.getUserSession(sessionId, userId); if (res) { diff --git a/packages/backend/server/src/core/auth/session-issuer.ts b/packages/backend/server/src/core/auth/session-issuer.ts new file mode 100644 index 0000000000..7efce627d9 --- /dev/null +++ b/packages/backend/server/src/core/auth/session-issuer.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/backend/server/src/core/selfhost/controller.ts b/packages/backend/server/src/core/selfhost/controller.ts index b81248f68c..32c84827bf 100644 --- a/packages/backend/server/src/core/selfhost/controller.ts +++ b/packages/backend/server/src/core/selfhost/controller.ts @@ -10,7 +10,7 @@ import { UseNamedGuard, } from '../../base'; import { Models } from '../../models'; -import { AuthService, Public } from '../auth'; +import { Public, SessionIssuer } from '../auth'; import { ServerService } from '../config'; import { validators } from '../utils/validators'; @@ -26,7 +26,7 @@ export class CustomSetupController { constructor( private readonly config: Config, private readonly models: Models, - private readonly auth: AuthService, + private readonly sessionIssuer: SessionIssuer, private readonly mutex: Mutex, private readonly server: ServerService ) {} @@ -72,7 +72,10 @@ export class CustomSetupController { 'selfhost setup' ); - await this.auth.setCookies(req, res, user.id); + await this.sessionIssuer.issue(req, res, { + userId: user.id, + method: 'password', + }); res.send({ id: user.id, email: user.email, name: user.name }); } catch (e) { await this.models.user.delete(user.id); diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index 9d94ed10a3..5585dcd67b 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -2,6 +2,7 @@ declare namespace Express { interface Request { session?: import('./core/auth/session').Session; token?: import('./core/auth/session').TokenSession; + authType?: 'jwt' | 'session' | 'access_token'; } } diff --git a/packages/backend/server/src/models/verification-token.ts b/packages/backend/server/src/models/verification-token.ts index faf1511aa4..c049309db8 100644 --- a/packages/backend/server/src/models/verification-token.ts +++ b/packages/backend/server/src/models/verification-token.ts @@ -13,8 +13,6 @@ export enum TokenType { VerifyEmail, ChangeEmail, ChangePassword, - Challenge, - OpenAppSignIn, } @Injectable() diff --git a/packages/backend/server/src/plugins/captcha/service.ts b/packages/backend/server/src/plugins/captcha/service.ts index 679cbc0ba4..c1d1c33fbe 100644 --- a/packages/backend/server/src/plugins/captcha/service.ts +++ b/packages/backend/server/src/plugins/captcha/service.ts @@ -12,7 +12,7 @@ import { OnEvent, } from '../../base'; import { ServerFeature, ServerService } from '../../core'; -import { Models, TokenType } from '../../models'; +import { AuthChallengeStore } from '../../core/auth'; import { verifyChallengeResponse } from '../../native'; import { CaptchaConfig } from './types'; @@ -28,7 +28,7 @@ export class CaptchaService { constructor( private readonly config: Config, - private readonly models: Models, + private readonly challenges: AuthChallengeStore, private readonly server: ServerService ) { this.captcha = config.captcha.config; @@ -93,10 +93,10 @@ export class CaptchaService { async getChallengeToken() { const resource = randomUUID(); - const challenge = await this.models.verificationToken.create( - TokenType.Challenge, + const challenge = await this.challenges.create( + 'captcha', resource, - 5 * 60 + 5 * 60 * 1000 ); return { @@ -117,9 +117,7 @@ export class CaptchaService { const challenge = credential.challenge; let resource: string | null = null; if (typeof challenge === 'string' && challenge) { - resource = await this.models.verificationToken - .get(TokenType.Challenge, challenge) - .then(token => token?.credential || null); + resource = await this.challenges.consume('captcha', challenge); } if (resource) { diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 97965a1090..6350dd9260 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -3,68 +3,63 @@ import { Controller, HttpCode, HttpStatus, - Logger, Post, type RawBodyRequest, Req, Res, } from '@nestjs/common'; -import { ConnectedAccount } from '@prisma/client'; import type { Request, Response } from 'express'; import { ActionForbidden, - Config, getClientVersionFromRequest, - InvalidAuthState, - InvalidOauthCallbackState, MissingOauthQueryParameter, - OauthAccountAlreadyConnected, - OauthStateExpired, - SignUpForbidden, UnknownOauthProvider, URLHelper, UseNamedGuard, } from '../../base'; -import { AuthService, Public } from '../../core/auth'; -import { Models } from '../../models'; +import { + OAuthCallbackBodySchema, + OAuthPreflightBodySchema, + Public, + SessionIssuer, +} from '../../core/auth'; import { OAuthProviderName } from './config'; import { OAuthProviderFactory } from './factory'; -import { OAuthAccount, Tokens } from './providers/def'; import { OAuthService } from './service'; @Controller('/api/oauth') export class OAuthController { - private readonly logger = new Logger(OAuthController.name); - constructor( - private readonly auth: AuthService, + private readonly sessionIssuer: SessionIssuer, private readonly oauth: OAuthService, - private readonly models: Models, private readonly providerFactory: OAuthProviderFactory, - private readonly url: URLHelper, - private readonly config: Config + private readonly url: URLHelper ) {} @Public() @UseNamedGuard('version') @Post('/preflight') @HttpCode(HttpStatus.OK) - async preflight( - @Req() req: Request, - @Body('provider') unknownProviderName?: keyof typeof OAuthProviderName, - @Body('redirect_uri') redirectUri?: string, - @Body('client') client?: string, - @Body('client_nonce') clientNonce?: string - ) { - if (!unknownProviderName) { + async preflight(@Req() req: Request, @Body() body?: unknown) { + const input = OAuthPreflightBodySchema.safeParse(body); + if (!input.success) { + const fields = new Set(input.error.issues.map(issue => issue.path[0])); + if (fields.has('client_nonce')) { + throw new MissingOauthQueryParameter({ name: 'client_nonce' }); + } throw new MissingOauthQueryParameter({ name: 'provider' }); } - if (!clientNonce) { - throw new MissingOauthQueryParameter({ name: 'client_nonce' }); - } - const providerName = OAuthProviderName[unknownProviderName]; + const { + provider: unknownProviderName, + redirect_uri: redirectUri, + client, + client_nonce: clientNonce, + } = input.data; + + const providerName = + OAuthProviderName[unknownProviderName as keyof typeof OAuthProviderName]; const provider = this.providerFactory.get(providerName); if (!provider) { @@ -123,57 +118,38 @@ export class OAuthController { async callback( @Req() req: RawBodyRequest, @Res() res: Response, - @Body('code') code?: string, - @Body('state') stateStr?: string, - @Body('client_nonce') clientNonce?: string + @Body() body?: unknown ) { - // TODO(@forehalo): refactor and remove deprecated code in 0.23 - if (!code) { - throw new MissingOauthQueryParameter({ name: 'code' }); - } - - if (!stateStr) { + const input = OAuthCallbackBodySchema.safeParse(body); + if (!input.success) { + const fields = new Set(input.error.issues.map(issue => issue.path[0])); + if (fields.has('code')) { + throw new MissingOauthQueryParameter({ name: 'code' }); + } + if (fields.has('state')) { + throw new MissingOauthQueryParameter({ name: 'state' }); + } throw new MissingOauthQueryParameter({ name: 'state' }); } - // NOTE(@forehalo): Apple sign in will directly post /callback, with `state` set at #L73 - let rawState = null; - if (typeof stateStr === 'string' && stateStr.length > 36) { - try { - rawState = JSON.parse(stateStr); - stateStr = rawState.state; - } catch { - /* noop */ - } - } + const { code, state: stateStr, client_nonce: clientNonce } = input.data; - if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) { - throw new InvalidOauthCallbackState(); - } + const verified = await this.oauth.verifyCallback({ + code, + stateStr, + clientNonce, + rawBody: req.rawBody, + }); - const state = await this.oauth.getOAuthState(stateStr); - - if (!state) { - throw new OauthStateExpired(); - } - if (!state.token) { - state.token = stateStr; - } - - if ( - state.provider === OAuthProviderName.Apple && - rawState && - state.client && - state.client !== 'web' - ) { - const clientUrl = new URL(`${state.client}://authentication`); + if (verified.type === 'handoff') { + const clientUrl = new URL(`${verified.state.client}://authentication`); clientUrl.searchParams.set('method', 'oauth'); clientUrl.searchParams.set( 'payload', JSON.stringify({ - state: stateStr, + state: verified.stateToken, code, - provider: rawState.provider, + provider: verified.provider, }) ); clientUrl.searchParams.set('server', this.url.requestOrigin); @@ -185,46 +161,8 @@ export class OAuthController { ); } - if (!state.provider) { - throw new MissingOauthQueryParameter({ name: 'provider' }); - } - - const provider = this.providerFactory.get(state.provider); - - if (!provider) { - throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' }); - } - - if ( - state.provider !== OAuthProviderName.Apple && - (!clientNonce || !state.clientNonce || state.clientNonce !== clientNonce) - ) { - throw new InvalidAuthState(); - } - - let tokens: Tokens; - try { - tokens = await provider.getToken(code, state); - } catch (err) { - let rayBodyString = ''; - if (req.rawBody) { - // only log the first 4096 bytes of the raw body - rayBodyString = req.rawBody.subarray(0, 4096).toString('utf-8'); - } - this.logger.warn( - `Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rayBodyString}, error: ${err}` - ); - throw err; - } - - const externAccount = await provider.getUser(tokens, state); - const user = await this.getOrCreateUserFromOauth( - state.provider, - externAccount, - tokens - ); - - await this.auth.setCookies(req, res, user.id, state.clientVersion); + const { identity, state } = verified; + const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity); if ( state.provider === OAuthProviderName.Apple && @@ -234,96 +172,9 @@ export class OAuthController { } res.send({ - id: user.id, + id: identity.userId, + exchangeCode, redirectUri: state.redirectUri, }); } - - private async getOrCreateUserFromOauth( - provider: OAuthProviderName, - externalAccount: OAuthAccount, - tokens: Tokens - ) { - const connectedAccount = await this.models.user.getConnectedAccount( - provider, - externalAccount.id - ); - - if (connectedAccount) { - // already connected - await this.updateConnectedAccount(connectedAccount, tokens); - - if ( - !connectedAccount.user.emailVerifiedAt && - // external email may change, check if it matches exists email - externalAccount.email.toLowerCase() === - connectedAccount.user.email.toLowerCase() - ) { - await this.auth.setEmailVerified(connectedAccount.userId); - } - return connectedAccount.user; - } - - if (!this.config.auth.allowSignupForOauth) { - throw new SignUpForbidden(); - } - - const user = await this.models.user.fulfill(externalAccount.email, { - name: externalAccount.name, - avatarUrl: externalAccount.avatarUrl, - }); - - await this.models.user.createConnectedAccount({ - userId: user.id, - provider, - providerAccountId: externalAccount.id, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }); - - return user; - } - - private async updateConnectedAccount( - connectedAccount: ConnectedAccount, - tokens: Tokens - ) { - return await this.models.user.updateConnectedAccount(connectedAccount.id, { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }); - } - - /** - * we currently don't support connect oauth account to existing user - * keep it incase we need it in the future - */ - // @ts-expect-error allow unused - private async _connectAccount( - user: { id: string }, - provider: OAuthProviderName, - externalAccount: OAuthAccount, - tokens: Tokens - ) { - const connectedAccount = await this.models.user.getConnectedAccount( - provider, - externalAccount.id - ); - if (connectedAccount) { - if (connectedAccount.userId !== user.id) { - throw new OauthAccountAlreadyConnected(); - } - } else { - await this.models.user.createConnectedAccount({ - userId: user.id, - provider, - providerAccountId: externalAccount.id, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }); - } - } } diff --git a/packages/backend/server/src/plugins/oauth/index.ts b/packages/backend/server/src/plugins/oauth/index.ts index 14f5f90146..140ebd6349 100644 --- a/packages/backend/server/src/plugins/oauth/index.ts +++ b/packages/backend/server/src/plugins/oauth/index.ts @@ -3,7 +3,7 @@ import './config'; import { Module } from '@nestjs/common'; import { ServerConfigModule } from '../../core'; -import { AuthModule } from '../../core/auth'; +import { AUTH_OAUTH_PROVIDER_READER, AuthModule } from '../../core/auth'; import { UserModule } from '../../core/user'; import { OAuthController } from './controller'; import { OAuthProviderFactory } from './factory'; @@ -15,6 +15,7 @@ import { OAuthService } from './service'; imports: [AuthModule, UserModule, ServerConfigModule], providers: [ OAuthProviderFactory, + { provide: AUTH_OAUTH_PROVIDER_READER, useExisting: OAuthProviderFactory }, OAuthService, OAuthResolver, ...OAuthProviders, diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts index 67da06bde2..3da3afb66c 100644 --- a/packages/backend/server/src/plugins/oauth/service.ts +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -1,18 +1,57 @@ -import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConnectedAccount } from '@prisma/client'; -import { SessionCache } from '../../base'; +import { + Config, + InvalidAuthState, + InvalidOauthCallbackState, + MissingOauthQueryParameter, + OauthStateExpired, + SignUpForbidden, + UnknownOauthProvider, +} from '../../base'; +import { + AuthChallengeStore, + AuthService, + OAuthStateEnvelopeSchema, + type VerifiedIdentity, +} from '../../core/auth'; +import { Models } from '../../models'; +import { OAuthProviderName } from './config'; import { OAuthProviderFactory } from './factory'; +import { OAuthAccount, Tokens } from './providers/def'; import { OAuthPkceChallenge, OAuthState } from './types'; -const OAUTH_STATE_KEY = 'OAUTH_STATE'; +type HandoffResult = { + type: 'handoff'; + code: string; + provider: unknown; + state: OAuthState; + stateToken: string; +}; + +type IdentityResult = { + type: 'identity'; + identity: VerifiedIdentity; + state: OAuthState; +}; + +type VerifyCallbackResult = HandoffResult | IdentityResult; + +const OAUTH_STATE_TTL_MS = 3600 * 3 * 1000; @Injectable() export class OAuthService { + private readonly logger = new Logger(OAuthService.name); + constructor( private readonly providerFactory: OAuthProviderFactory, - private readonly cache: SessionCache + private readonly challenges: AuthChallengeStore, + private readonly auth: AuthService, + private readonly models: Models, + private readonly config: Config ) {} isValidState(stateStr: string) { @@ -20,23 +59,191 @@ export class OAuthService { } async saveOAuthState(state: OAuthState) { - const token = randomUUID(); - const payload: OAuthState = { ...state, token }; - await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, { - ttl: 3600 * 3 * 1000 /* 3 hours */, - }); - - return token; + return this.challenges.create( + 'oauth_state', + token => ({ ...state, token }), + OAUTH_STATE_TTL_MS + ); } async getOAuthState(token: string) { - return this.cache.get(`${OAUTH_STATE_KEY}:${token}`); + return this.challenges.get('oauth_state', token); } availableOAuthProviders() { return this.providerFactory.providers; } + async verifyCallback(input: { + code: string; + stateStr: string; + clientNonce?: string; + rawBody?: Buffer; + }): Promise { + 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 { + if (!state.provider) { + throw new UnknownOauthProvider({ name: 'unknown' }); + } + + const provider = this.providerFactory.get(state.provider); + + if (!provider) { + throw new UnknownOauthProvider({ name: state.provider }); + } + + let tokens: Tokens; + try { + tokens = await provider.getToken(code, state); + } catch (err) { + const rawBodyString = rawBody + ? rawBody.subarray(0, 4096).toString('utf-8') + : ''; + this.logger.warn( + `Error getting oauth token for ${state.provider}, callback code: ${code}, stateStr: ${stateStr}, rawBody: ${rawBodyString}, error: ${err}` + ); + throw err; + } + + const externalAccount = await provider.getUser(tokens, state); + const user = await this.getOrCreateUserFromOauth( + state.provider, + externalAccount, + tokens + ); + + return { + userId: user.id, + method: 'oauth', + clientVersion: state.clientVersion, + }; + } + + private async getOrCreateUserFromOauth( + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + const connectedAccount = await this.models.user.getConnectedAccount( + provider, + externalAccount.id + ); + + if (connectedAccount) { + await this.updateConnectedAccount(connectedAccount, tokens); + + if ( + !connectedAccount.user.emailVerifiedAt && + externalAccount.email.toLowerCase() === + connectedAccount.user.email.toLowerCase() + ) { + await this.auth.setEmailVerified(connectedAccount.userId); + } + return connectedAccount.user; + } + + if (!this.config.auth.allowSignupForOauth) { + throw new SignUpForbidden(); + } + + const user = await this.models.user.fulfill(externalAccount.email, { + name: externalAccount.name, + avatarUrl: externalAccount.avatarUrl, + }); + + await this.models.user.createConnectedAccount({ + userId: user.id, + provider, + providerAccountId: externalAccount.id, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }); + + return user; + } + + private async updateConnectedAccount( + connectedAccount: ConnectedAccount, + tokens: Tokens + ) { + return await this.models.user.updateConnectedAccount(connectedAccount.id, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }); + } + createPkcePair(): OAuthPkceChallenge { const codeVerifier = this.randomBase64Url(96); const hash = createHash('sha256').update(codeVerifier).digest(); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index e882eb0167..f52ee6e77a 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2679,7 +2679,7 @@ type UserType { """Get user settings""" settings: UserSettingsType! subscriptions: [SubscriptionType!]! - token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead") + token: tokenType! @deprecated(reason: "use native session exchange instead") } type ValidationErrorDataType { diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index b587b45d92..d3ecb5c530 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -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 = { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 6f626a4cda..a0e2e81b5e 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -3409,7 +3409,7 @@ export interface UserType { /** Get user settings */ settings: UserSettingsType; subscriptions: Array; - /** @deprecated use [/api/auth/sign-in?native=true] instead */ + /** @deprecated use native session exchange instead */ token: TokenType; } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt index 95776c6a1b..0e92676df6 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt @@ -1,9 +1,6 @@ package app.affine.pro 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.logger.FileTree import com.getcapacitor.Bridge @@ -11,7 +8,6 @@ import com.getcapacitor.WebViewListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch -import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import timber.log.Timber @@ -23,30 +19,11 @@ object AuthInitializer { bridge.removeWebViewListener(this) MainScope().launch(Dispatchers.IO) { try { - val server = bridge.getCurrentServerBaseUrl().toHttpUrl() - val sessionCookieStr = AFFiNEApp.context().dataStore - .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 ]"), + FileTree.get()?.checkAndUploadOldLogs( + bridge.getCurrentServerBaseUrl().toHttpUrl() ) - CookieStore.saveCookies(server.host, cookies) - FileTree.get()?.checkAndUploadOldLogs(server) } catch (e: Exception) { - Timber.w(e, "[init] load persistent cookies fail.") + Timber.w(e, "[init] auth initializer fail.") } } } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt index 4dba57ab80..64a6dc10af 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt @@ -1,8 +1,16 @@ package app.affine.pro.plugin 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.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.Plugin import com.getcapacitor.PluginCall @@ -10,6 +18,7 @@ import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request @@ -17,10 +26,90 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.coroutines.executeAsync import org.json.JSONObject 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) @CapacitorPlugin(name = "Auth") 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 fun signInMagicLink(call: PluginCall) { @@ -32,6 +121,11 @@ class AuthPlugin : Plugin() { processSignIn(call, SignInMethod.Oauth) } + @PluginMethod + fun signInOpenApp(call: PluginCall) { + processSignIn(call, SignInMethod.OpenApp) + } + @SuppressLint("BuildListAdds") @PluginMethod fun signInPassword(call: PluginCall) { @@ -43,21 +137,22 @@ class AuthPlugin : Plugin() { launch(Dispatchers.IO) { try { val endpoint = call.getStringEnsure("endpoint") - val csrfToken = CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_CSRF_TOKEN) + val token = call.getString("token") val request = Request.Builder() .url("$endpoint/api/auth/sign-out") .post("".toRequestBody("application/json".toMediaTypeOrNull())) .apply { - if (csrfToken != null) { - addHeader("x-affine-csrf-token", csrfToken) + if (token != null) { + addHeader("Authorization", "Bearer $token") } } .build() - OkHttp.client.newCall(request).executeAsync().use { response -> + AuthHttp.client.newCall(request).executeAsync().use { response -> if (response.code >= 400) { call.reject(response.body.string()) return@launch } + CookieStore.clearAuthCookies(endpoint.toHttpUrl().host) Timber.i("Sign out success.") call.resolve(JSObject().put("ok", true)) } @@ -69,7 +164,7 @@ class AuthPlugin : Plugin() { } private enum class SignInMethod { - Password, Oauth, MagicLink + Password, Oauth, MagicLink, OpenApp } private fun processSignIn(call: PluginCall, method: SignInMethod) { @@ -92,6 +187,7 @@ class AuthPlugin : Plugin() { val requestBuilder = Request.Builder() .url("$endpoint/api/auth/sign-in") + .addHeader("x-affine-client-kind", "native") .post(body) if (verifyToken != null) { requestBuilder.addHeader("x-captcha-token", verifyToken) @@ -117,6 +213,7 @@ class AuthPlugin : Plugin() { Request.Builder() .url("$endpoint/api/oauth/callback") + .addHeader("x-affine-client-kind", "native") .post(body) .build() } @@ -136,19 +233,41 @@ class AuthPlugin : Plugin() { Request.Builder() .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) .build() } } - OkHttp.client.newCall(request).executeAsync().use { response -> + AuthHttp.client.newCall(request).executeAsync().use { response -> if (response.code >= 400) { call.reject(response.body.string()) 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.d("Update session [$it]") call.resolve(JSObject().put("token", it)) } ?: run { 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() + } } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt index 2827b01b59..2c52623f5f 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt @@ -3,6 +3,7 @@ package app.affine.pro.service import app.affine.pro.AFFiNEApp import app.affine.pro.CapacitorConfig import app.affine.pro.utils.dataStore +import app.affine.pro.utils.del import app.affine.pro.utils.set import com.google.firebase.crashlytics.ktx.crashlytics 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 { const val AFFINE_SESSION = "affine_session" @@ -61,9 +76,6 @@ object CookieStore { fun saveCookies(host: String, cookies: List) { _cookies[host] = cookies 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 { Timber.d("Update user id [${it.value}]") AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString()) @@ -77,6 +89,18 @@ object CookieStore { 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 .let { _cookies[it] } ?.find { cookie -> cookie.name == name } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt index 3f3ed1ce0e..31304f3f24 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt @@ -17,6 +17,12 @@ suspend fun DataStore.set(key: String, value: String) { } } +suspend fun DataStore.del(key: String) { + edit { + it.remove(stringPreferencesKey(key)) + } +} + suspend fun DataStore.get(key: String) = data.map { it[stringPreferencesKey(key)] ?: "" -}.first() \ No newline at end of file +}.first() diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index 2d2ab9a605..9aa8953d48 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -57,7 +57,11 @@ import { Auth } from './plugins/auth'; import { HashCash } from './plugins/hashcash'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { Preview } from './plugins/preview'; -import { writeEndpointToken } from './proxy'; +import { + deleteEndpointToken, + readEndpointToken, + writeEndpointToken, +} from './proxy'; const storeManagerClient = createStoreManagerClient(); setTelemetryTransport(storeManagerClient.telemetry); @@ -206,10 +210,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => { }); await writeEndpointToken(endpoint, token); }, - async signOut() { - await Auth.signOut({ + async signInOpenAppSignInCode(code) { + const { token } = await Auth.signInOpenApp({ 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] ); + + 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)); } diff --git a/packages/frontend/apps/android/src/nbstore.worker.ts b/packages/frontend/apps/android/src/nbstore.worker.ts index f2e2d93b82..1ababc38c3 100644 --- a/packages/frontend/apps/android/src/nbstore.worker.ts +++ b/packages/frontend/apps/android/src/nbstore.worker.ts @@ -18,19 +18,28 @@ import { import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; -import { readEndpointToken } from './proxy'; +let authTokenPort: MessagePort | undefined; +const pendingTokenRequests = new Map void>(); configureSocketAuthMethod((endpoint, cb) => { readEndpointToken(endpoint) - .then(token => { - cb({ token }); - }) - .catch(e => { - console.error(e); - }); + .then(token => cb(token ? { token, tokenType: 'jwt' } : {})) + .catch(() => cb({})); }); 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') { const port = e.ports[0] as MessagePort; const rpc = AsyncCall( @@ -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(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( globalThis as MessageCommunicapable ); diff --git a/packages/frontend/apps/android/src/plugins/auth/definitions.ts b/packages/frontend/apps/android/src/plugins/auth/definitions.ts index 8e5ffae4ad..f432fcfaa0 100644 --- a/packages/frontend/apps/android/src/plugins/auth/definitions.ts +++ b/packages/frontend/apps/android/src/plugins/auth/definitions.ts @@ -18,5 +18,17 @@ export interface AuthPlugin { verifyToken?: string; challenge?: string; }): Promise<{ token: string }>; - signOut(options: { endpoint: string }): Promise; + signInOpenApp(options: { + endpoint: string; + code: string; + }): Promise<{ token: string }>; + signOut(options: { endpoint: string; token?: string | null }): Promise; + readEndpointToken(options: { + endpoint: string; + }): Promise<{ token?: string | null }>; + writeEndpointToken(options: { + endpoint: string; + token: string; + }): Promise; + deleteEndpointToken(options: { endpoint: string }): Promise; } diff --git a/packages/frontend/apps/android/src/proxy.ts b/packages/frontend/apps/android/src/proxy.ts index 396dad354b..963d3d1936 100644 --- a/packages/frontend/apps/android/src/proxy.ts +++ b/packages/frontend/apps/android/src/proxy.ts @@ -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. @@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 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) { request.headers.set('Authorization', `Bearer ${token}`); } @@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { }; const rawXMLHttpRequest = globalThis.XMLHttpRequest; +const xhrRequestUrls = new WeakMap(); globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { - override send(body?: Document | XMLHttpRequestBodyInit | null): void { - const origin = new URL(this.responseURL, globalThis.location.origin).origin; + override open( + 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 => { if (token) { this.setRequestHeader('Authorization', `Bearer ${token}`); @@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { 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( endpoint: string ): Promise { - const idb = await openDB('affine-token', 1, { - upgrade(db) { - if (!db.objectStoreNames.contains('tokens')) { - db.createObjectStore('tokens', { keyPath: 'endpoint' }); - } - }, + const { token } = await Auth.readEndpointToken({ + endpoint: canonicalEndpoint(endpoint), }); - - const token = await idb.get('tokens', endpoint); - return token ? token.token : null; + return token ?? null; } export async function writeEndpointToken(endpoint: string, token: string) { - const db = await openDB('affine-token', 1, { - upgrade(db) { - if (!db.objectStoreNames.contains('tokens')) { - db.createObjectStore('tokens', { keyPath: 'endpoint' }); - } - }, + await Auth.writeEndpointToken({ + endpoint: canonicalEndpoint(endpoint), + token, }); - - await db.put('tokens', { endpoint, token }); +} + +export async function deleteEndpointToken(endpoint: string) { + await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) }); } diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts index 8e14b87820..3822d13731 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts @@ -2,7 +2,12 @@ import { configureElectronStateStorageImpls } from '@affine/core/desktop/storage import { configureCommonModules } from '@affine/core/modules'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; 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 { configureDesktopApiModule, 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(); diff --git a/packages/frontend/apps/electron-renderer/src/background-worker/index.ts b/packages/frontend/apps/electron-renderer/src/background-worker/index.ts index 6df4d0e61c..fd81001331 100644 --- a/packages/frontend/apps/electron-renderer/src/background-worker/index.ts +++ b/packages/frontend/apps/electron-renderer/src/background-worker/index.ts @@ -2,7 +2,10 @@ import '@affine/core/bootstrap/electron'; import { apis } from '@affine/electron-api'; 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 { bindNativeDBV1Apis, @@ -18,6 +21,15 @@ import { OpConsumer } from '@toeverything/infra/op'; bindNativeDBApis(apis!.nbstore); // oxlint-disable-next-line no-non-null-assertion 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([ ...sqliteStorages, diff --git a/packages/frontend/apps/electron/src/main/auth/handlers.ts b/packages/frontend/apps/electron/src/main/auth/handlers.ts new file mode 100644 index 0000000000..e11e017279 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/auth/handlers.ts @@ -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(response: Response): Promise { + 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); + 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(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; diff --git a/packages/frontend/apps/electron/src/main/auth/native-token.ts b/packages/frontend/apps/electron/src/main/auth/native-token.ts new file mode 100644 index 0000000000..69120dfc5c --- /dev/null +++ b/packages/frontend/apps/electron/src/main/auth/native-token.ts @@ -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 { + 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) { + 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; + } +} diff --git a/packages/frontend/apps/electron/src/main/handlers.ts b/packages/frontend/apps/electron/src/main/handlers.ts index bc9e0f49c9..b11ffc592b 100644 --- a/packages/frontend/apps/electron/src/main/handlers.ts +++ b/packages/frontend/apps/electron/src/main/handlers.ts @@ -2,6 +2,7 @@ import { I18n } from '@affine/i18n'; import { ipcMain } from 'electron'; import { AFFINE_API_CHANNEL_NAME } from '../shared/type'; +import { authHandlers } from './auth/handlers'; import { byokStorageHandlers } from './byok-storage/handlers'; import { clipboardHandlers } from './clipboard'; import { configStorageHandlers } from './config-storage'; @@ -44,6 +45,7 @@ export const allHandlers = { popup: popupHandlers, i18n: i18nHandlers, byokStorage: byokStorageHandlers, + auth: authHandlers, }; export const registerHandlers = () => { diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts index 2f5bcb5ca8..46db8356e0 100644 --- a/packages/frontend/apps/electron/src/main/protocol.ts +++ b/packages/frontend/apps/electron/src/main/protocol.ts @@ -2,7 +2,6 @@ import path, { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { app, net, protocol, session } from 'electron'; -import cookieParser from 'set-cookie-parser'; import { anotherHost, mainHost } from '../shared/internal-origin'; import { @@ -12,6 +11,7 @@ import { resolvePathInBase, resourcesPath, } from '../shared/utils'; +import { getAuthTokenForUrl } from './auth/native-token'; import { buildType, isDev } from './config'; import { logger } from './logger'; @@ -64,7 +64,27 @@ function buildTargetUrl(base: string, urlObject: URL) { 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, urlObject: URL, base: string, @@ -72,12 +92,13 @@ function proxyRequest( ) { const { bypassCustomProtocolHandlers = true } = options; const targetUrl = buildTargetUrl(base, urlObject); + const authorizedRequest = await buildAuthorizedRequest(request, targetUrl); const proxiedRequest = bypassCustomProtocolHandlers - ? Object.assign(request.clone(), { + ? Object.assign(authorizedRequest, { bypassCustomProtocolHandlers: true, }) - : request; - return net.fetch(targetUrl, proxiedRequest); + : authorizedRequest; + return net.fetch(proxiedRequest); } async function handleFileRequest(request: Request) { @@ -218,41 +239,6 @@ export function registerProtocol() { const { responseHeaders, url } = responseDetails; (async () => { 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); // 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); (async () => { - // session cookies are set to assets:// on production - // if sending request to the cloud, attach the session cookie (to affine cloud server) if ( url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ws:' || url.protocol === 'wss:' ) { - const cookies = await session.defaultSession.cookies.get({ - url: details.url, - }); - - const cookieString = cookies - .map(c => `${c.name}=${c.value}`) - .join('; '); - delete details.requestHeaders['cookie']; - details.requestHeaders['Cookie'] = cookieString; + const token = getAuthTokenForUrl(details.url); + if (token) { + delete details.requestHeaders.authorization; + details.requestHeaders.Authorization = `Bearer ${token}`; + } } const hostname = url.hostname; diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift index d72a0b21fb..fa2f26c032 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -1,5 +1,6 @@ import Capacitor import Foundation +import Security public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AuthPlugin" @@ -7,10 +8,70 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signInPassword", 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) { Task { do { @@ -19,7 +80,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { let token = try call.getStringEnsure("token") 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 let textBody = String(data: data, encoding: .utf8) { @@ -30,12 +95,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { return } - guard let token = try self.tokenFromCookie(endpoint) else { - call.reject("token not found") - return - } - - call.resolve(["token": token]) + call.resolve(["token": try await self.exchangeSession(endpoint, data)]) } catch { 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 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 let textBody = String(data: data, encoding: .utf8) { @@ -61,12 +125,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { return } - guard let token = try self.tokenFromCookie(endpoint) else { - call.reject("token not found") - return - } - - call.resolve(["token": token]) + call.resolve(["token": try await self.exchangeSession(endpoint, data)]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } @@ -82,10 +141,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { let verifyToken = call.getString("verifyToken") let challenge = call.getString("challenge") - let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [ - "x-captcha-token": verifyToken, - "x-captcha-challenge": challenge, - ], body: ["email": email, "password": password]) + let (data, response) = try await self.fetch( + endpoint, method: "POST", action: "/api/auth/sign-in", + headers: [ + "x-affine-client-kind": "native", + "x-captcha-token": verifyToken, + "x-captcha-challenge": challenge, + ], body: ["email": email, "password": password]) if response.statusCode >= 400 { if let textBody = String(data: data, encoding: .utf8) { @@ -96,12 +158,35 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { return } - guard let token = try self.tokenFromCookie(endpoint) else { - call.reject("token not found") + call.resolve(["token": try await self.exchangeSession(endpoint, data)]) + } 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 } - call.resolve(["token": token]) + call.resolve(["token": try await self.exchangeSession(endpoint, data)]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } @@ -112,11 +197,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { Task { do { 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: [ - "x-affine-csrf-token": csrfToken, - ], body: nil) + let (data, response) = try await self.fetch( + endpoint, method: "POST", action: "/api/auth/sign-out", + headers: [ + "Authorization": token.map { "Bearer \($0)" } + ], body: nil) if response.statusCode >= 400 { if let textBody = String(data: data, encoding: .utf8) { @@ -127,6 +214,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { return } + self.clearAuthCookies(endpoint) call.resolve(["ok": true]) } catch { 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? { - guard let endpointUrl = URL(string: endpoint) else { - throw AuthError.invalidEndpoint + private func tokenFromResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = json["token"] as? String + else { + throw AuthError.tokenNotFound } - if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { - $0.name == "affine_session" - }) { - return cookie.value - } else { - return nil + return token + } + + private func exchangeCodeFromResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + 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? { - guard let endpointUrl = URL(string: endpoint) else { - throw AuthError.invalidEndpoint - } - - return HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { - $0.name == "affine_csrf_token" - })?.value + private func tokenQuery(_ endpoint: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: tokenService, + kSecAttrAccount as String: canonicalEndpoint(endpoint), + ] } - 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 { throw AuthError.invalidEndpoint } var request = URLRequest(url: targetUrl) request.httpMethod = method - request.httpShouldHandleCookies = true + request.httpShouldHandleCookies = false for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } @@ -174,7 +371,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { request.httpBody = try JSONEncoder().encode(body!) } 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) guard let httpResponse = response as? HTTPURLResponse else { @@ -185,5 +382,5 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { } enum AuthError: Error { - case invalidEndpoint, internalError + case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed } diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserQuery.graphql.swift index 2c4825f097..96633a2124 100644 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserQuery.graphql.swift +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserQuery.graphql.swift @@ -57,7 +57,7 @@ public class GetCurrentUserQuery: GraphQLQuery { public var emailVerified: Bool { __data["emailVerified"] } /// User avatar url 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"] } /// CurrentUser.Token diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index a4c99a07bb..bd90e3de21 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -74,7 +74,11 @@ import { Hashcash } from './plugins/hashcash'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { PayWall } from './plugins/paywall'; import { Preview } from './plugins/preview'; -import { writeEndpointToken } from './proxy'; +import { + deleteEndpointToken, + readEndpointToken, + writeEndpointToken, +} from './proxy'; import { enableNavigationGesture$ } from './web-navigation-control'; const storeManagerClient = createStoreManagerClient(); @@ -204,10 +208,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => { }); await writeEndpointToken(endpoint, token); }, - async signOut() { - await Auth.signOut({ + async signInOpenAppSignInCode(code) { + const { token } = await Auth.signInOpenApp({ 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(NbStoreNativeDBApis, { channel: { on(listener) { - const f = (e: MessageEvent) => { - listener(e.data); - }; + const f = (e: MessageEvent) => listener(e.data); nativeDBApiChannelServer.addEventListener('message', f); - return () => { - nativeDBApiChannelServer.removeEventListener('message', f); - }; + return () => nativeDBApiChannelServer.removeEventListener('message', f); }, send(data) { nativeDBApiChannelServer.postMessage(data); @@ -557,11 +567,23 @@ function createStoreManagerClient() { }); nativeDBApiChannelServer.start(); worker.postMessage( - { - type: 'native-db-api-channel', - port: nativeDBApiChannelClient, - }, + { type: 'native-db-api-channel', port: 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)); } diff --git a/packages/frontend/apps/ios/src/nbstore.worker.ts b/packages/frontend/apps/ios/src/nbstore.worker.ts index f2e2d93b82..1ababc38c3 100644 --- a/packages/frontend/apps/ios/src/nbstore.worker.ts +++ b/packages/frontend/apps/ios/src/nbstore.worker.ts @@ -18,19 +18,28 @@ import { import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; -import { readEndpointToken } from './proxy'; +let authTokenPort: MessagePort | undefined; +const pendingTokenRequests = new Map void>(); configureSocketAuthMethod((endpoint, cb) => { readEndpointToken(endpoint) - .then(token => { - cb({ token }); - }) - .catch(e => { - console.error(e); - }); + .then(token => cb(token ? { token, tokenType: 'jwt' } : {})) + .catch(() => cb({})); }); 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') { const port = e.ports[0] as MessagePort; const rpc = AsyncCall( @@ -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(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( globalThis as MessageCommunicapable ); diff --git a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts index 8e5ffae4ad..f432fcfaa0 100644 --- a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts @@ -18,5 +18,17 @@ export interface AuthPlugin { verifyToken?: string; challenge?: string; }): Promise<{ token: string }>; - signOut(options: { endpoint: string }): Promise; + signInOpenApp(options: { + endpoint: string; + code: string; + }): Promise<{ token: string }>; + signOut(options: { endpoint: string; token?: string | null }): Promise; + readEndpointToken(options: { + endpoint: string; + }): Promise<{ token?: string | null }>; + writeEndpointToken(options: { + endpoint: string; + token: string; + }): Promise; + deleteEndpointToken(options: { endpoint: string }): Promise; } diff --git a/packages/frontend/apps/ios/src/proxy.ts b/packages/frontend/apps/ios/src/proxy.ts index 396dad354b..963d3d1936 100644 --- a/packages/frontend/apps/ios/src/proxy.ts +++ b/packages/frontend/apps/ios/src/proxy.ts @@ -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. @@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 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) { request.headers.set('Authorization', `Bearer ${token}`); } @@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { }; const rawXMLHttpRequest = globalThis.XMLHttpRequest; +const xhrRequestUrls = new WeakMap(); globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { - override send(body?: Document | XMLHttpRequestBodyInit | null): void { - const origin = new URL(this.responseURL, globalThis.location.origin).origin; + override open( + 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 => { if (token) { this.setRequestHeader('Authorization', `Bearer ${token}`); @@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { 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( endpoint: string ): Promise { - const idb = await openDB('affine-token', 1, { - upgrade(db) { - if (!db.objectStoreNames.contains('tokens')) { - db.createObjectStore('tokens', { keyPath: 'endpoint' }); - } - }, + const { token } = await Auth.readEndpointToken({ + endpoint: canonicalEndpoint(endpoint), }); - - const token = await idb.get('tokens', endpoint); - return token ? token.token : null; + return token ?? null; } export async function writeEndpointToken(endpoint: string, token: string) { - const db = await openDB('affine-token', 1, { - upgrade(db) { - if (!db.objectStoreNames.contains('tokens')) { - db.createObjectStore('tokens', { keyPath: 'endpoint' }); - } - }, + await Auth.writeEndpointToken({ + endpoint: canonicalEndpoint(endpoint), + token, }); - - await db.put('tokens', { endpoint, token }); +} + +export async function deleteEndpointToken(endpoint: string) { + await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) }); } diff --git a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts index 9c8a8b89cb..634069893e 100644 --- a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts +++ b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts @@ -41,7 +41,6 @@ describe('AuthService oauthPreflight', () => { framework.service(NbstoreService, { realtime: { subscribe: () => of() }, } as any); - framework.service(AuthService, [ FetchService, AuthStore, diff --git a/packages/frontend/core/src/components/sign-in/captcha.tsx b/packages/frontend/core/src/components/sign-in/captcha.tsx index cf2180693b..908dc12837 100644 --- a/packages/frontend/core/src/components/sign-in/captcha.tsx +++ b/packages/frontend/core/src/components/sign-in/captcha.tsx @@ -16,6 +16,7 @@ export const Captcha = () => { const handleTurnstileSuccess = useCallback( (token: string) => { + captchaService.challenge$.next(undefined); captchaService.verifyToken$.next(token); }, [captchaService] diff --git a/packages/frontend/core/src/components/sign-in/sign-in-with-email.tsx b/packages/frontend/core/src/components/sign-in/sign-in-with-email.tsx index 35ebce5873..ea7f80f30f 100644 --- a/packages/frontend/core/src/components/sign-in/sign-in-with-email.tsx +++ b/packages/frontend/core/src/components/sign-in/sign-in-with-email.tsx @@ -86,7 +86,6 @@ export const SignInWithEmailStep = ({ setIsSending(true); try { setResendCountDown(60); - captchaService.revalidate(); await authService.sendEmailMagicLink( email, verifyToken, @@ -100,6 +99,7 @@ export const SignInWithEmailStep = ({ title: 'Failed to sign in', message: t[`error.${error.name}`](error.data), }); + captchaService.revalidate(); } setIsSending(false); }, [ @@ -182,6 +182,7 @@ export const SignInWithEmailStep = ({ errorHint={otpError} onEnter={onContinue} type="text" + autoComplete="one-time-code" required={true} maxLength={6} /> diff --git a/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx b/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx index 34b11a5913..68ddc65932 100644 --- a/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx @@ -85,7 +85,6 @@ export const SignInWithPasswordStep = ({ setIsLoading(true); try { - captchaService.revalidate(); await authService.signInPassword({ email, password, @@ -111,6 +110,7 @@ export const SignInWithPasswordStep = ({ : t[`error.${error.name}`](error.data), }); } + captchaService.revalidate(); } finally { setIsLoading(false); } @@ -138,28 +138,50 @@ export const SignInWithPasswordStep = ({ /> - - { - setPassword(value); - if (passwordError) { - setPasswordError(false); - setPasswordErrorHint(t['com.affine.auth.password.error']()); - } +
{ + event.preventDefault(); + onSignIn(); }} - error={passwordError} - errorHint={passwordErrorHint} - onEnter={onSignIn} - /> + > + + { + setPassword(value); + if (passwordError) { + setPasswordError(false); + setPasswordErrorHint(t['com.affine.auth.password.error']()); + } + }} + error={passwordError} + errorHint={passwordErrorHint} + onEnter={onSignIn} + /> + {!verifyToken && needCaptcha && } + + {!isSelfhosted && ( )} - {!verifyToken && needCaptcha && } -
diff --git a/packages/frontend/core/src/components/sign-in/sign-in.tsx b/packages/frontend/core/src/components/sign-in/sign-in.tsx index e9bf6d7c86..04678561d3 100644 --- a/packages/frontend/core/src/components/sign-in/sign-in.tsx +++ b/packages/frontend/core/src/components/sign-in/sign-in.tsx @@ -90,7 +90,9 @@ export const SignInStep = ({ setIsMutating(true); 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) { changeState(prev => ({ @@ -99,13 +101,18 @@ export const SignInStep = ({ step: 'signInWithPassword', hasPassword: true, })); - } else { + } else if (canUseMagicLink) { changeState(prev => ({ ...prev, email, step: 'signInWithEmail', hasPassword: false, })); + } else { + notify.error({ + title: 'Failed to sign in', + message: 'This email is not available for sign in.', + }); } } catch (err: any) { console.error(err); @@ -151,31 +158,41 @@ export const SignInStep = ({ - - - + + + + {!isSelfhosted && ( <> diff --git a/packages/frontend/core/src/desktop/dialogs/change-password/index.tsx b/packages/frontend/core/src/desktop/dialogs/change-password/index.tsx index 3997a42a1b..0d5bc57879 100644 --- a/packages/frontend/core/src/desktop/dialogs/change-password/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/change-password/index.tsx @@ -25,6 +25,7 @@ import { useEffect, useState } from 'react'; export const ChangePasswordDialog = ({ close, + hasPassword: hasPasswordProp, server: serverBaseUrl, }: DialogComponentProps) => { const t = useI18n(); @@ -44,7 +45,8 @@ export const ChangePasswordDialog = ({ const authService = server.scope.get(AuthService); const account = useLiveData(authService.session.account$); const email = account?.email; - const hasPassword = account?.info?.hasPassword; + const hasPassword = + hasPasswordProp ?? account?.info?.authMethods?.password.bound ?? false; const [hasSentEmail, setHasSentEmail] = useState(false); const [loading, setLoading] = useState(false); const passwordLimits = useLiveData( diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx index a039b32bdc..23d3e5e3f0 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx @@ -201,13 +201,19 @@ export const AccountSetting = ({ const onPasswordButtonClick = useCallback(() => { globalDialogService.open('change-password', { + hasPassword: account?.info?.authMethods?.password.bound, server: serverService.server.baseUrl, }); - }, [globalDialogService, serverService.server.baseUrl]); + }, [ + account?.info?.authMethods?.password.bound, + globalDialogService, + serverService.server.baseUrl, + ]); if (!account) { return null; } + const hasPassword = account.info?.authMethods?.password.bound; return ( <> @@ -233,7 +239,7 @@ export const AccountSetting = ({ desc={t['com.affine.settings.password.message']()} > diff --git a/packages/frontend/core/src/modules/cloud/impl/auth.ts b/packages/frontend/core/src/modules/cloud/impl/auth.ts index b14e31b22f..5ca70c20c0 100644 --- a/packages/frontend/core/src/modules/cloud/impl/auth.ts +++ b/packages/frontend/core/src/modules/cloud/impl/auth.ts @@ -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() { const csrfToken = getCookieValue(CSRF_COOKIE_NAME); await fetchService.fetch('/api/auth/sign-out', { diff --git a/packages/frontend/core/src/modules/cloud/provider/auth.ts b/packages/frontend/core/src/modules/cloud/provider/auth.ts index f82f9e4940..5f34887174 100644 --- a/packages/frontend/core/src/modules/cloud/provider/auth.ts +++ b/packages/frontend/core/src/modules/cloud/provider/auth.ts @@ -21,6 +21,8 @@ export interface AuthProvider { challenge?: string; }): Promise; + signInOpenAppSignInCode(code: string): Promise; + signOut(): Promise; } diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index dd640398fc..fac156fdbc 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -222,11 +222,7 @@ export class AuthService extends Service { } async signInOpenAppSignInCode(code: string) { - await this.fetchService.fetch('/api/auth/open-app/sign-in', { - method: 'POST', - body: JSON.stringify({ code }), - headers: { 'content-type': 'application/json' }, - }); + await this.store.signInOpenAppSignInCode(code); this.session.revalidate(); } diff --git a/packages/frontend/core/src/modules/cloud/services/captcha.ts b/packages/frontend/core/src/modules/cloud/services/captcha.ts index fde5659978..6031be95f9 100644 --- a/packages/frontend/core/src/modules/cloud/services/captcha.ts +++ b/packages/frontend/core/src/modules/cloud/services/captcha.ts @@ -33,7 +33,7 @@ export class CaptchaService extends Service { revalidate = effect( exhaustMap(() => { return fromPromise(async signal => { - if (!this.needCaptcha$.value) { + if (!this.needCaptcha$.value || !this.validatorProvider) { return {}; } const res = await this.fetchService.fetch('/api/auth/challenge', { @@ -46,17 +46,14 @@ export class CaptchaService extends Service { if (!data || !data.challenge || !data.resource) { throw new Error('Invalid challenge'); } - if (this.validatorProvider) { - const token = await this.validatorProvider.validate( - data.challenge, - data.resource - ); - return { - token, - challenge: data.challenge, - }; - } - return { challenge: data.challenge, token: undefined }; + const token = await this.validatorProvider.validate( + data.challenge, + data.resource + ); + return { + token, + challenge: data.challenge, + }; }).pipe( tap(({ challenge, token }) => { this.verifyToken$.next(token); diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index 9874b5b138..0e76dcc114 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -19,6 +19,11 @@ export interface AccountProfile { email: string; name: string; hasPassword: boolean; + authMethods?: { + password: { bound: boolean }; + oauth: { bound: boolean; providers: string[] }; + passkey: { bound: boolean; count: number }; + }; avatarUrl: string | null; emailVerified: string | null; features?: string[]; @@ -61,16 +66,20 @@ export class AuthStore extends Store { } async fetchSession() { - const { user } = await this.nbstoreService.realtime.request( - 'user.profile.get', - {}, - { timeoutMs: 10000 } - ); + const { user } = await this.fetchService + .fetch('/api/auth/session') + .then(res => res.json()); + const authMethods = user + ? await this.fetchService + .fetch('/api/auth/methods') + .then(res => (res.ok ? res.json() : undefined)) + : undefined; return { user: user ? { ...user, hasPassword: Boolean(user.hasPassword), + authMethods, emailVerified: user.emailVerified ? 'true' : null, } : null, @@ -103,6 +112,10 @@ export class AuthStore extends Store { await this.authProvider.signInPassword(credential); } + async signInOpenAppSignInCode(code: string) { + await this.authProvider.signInOpenAppSignInCode(code); + } + async signOut() { await this.authProvider.signOut(); await this.nbstoreService.realtime.configure({ @@ -155,8 +168,12 @@ export class AuthStore extends Store { const data = (await res.json()) as { registered: boolean; - hasPassword: boolean; - magicLink: boolean; + methods: { + password: { available: boolean }; + magicLink: { available: boolean }; + oauth: { available: boolean; providers: string[] }; + passkey: { available: boolean; discoverable: boolean }; + }; }; return data; diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index 2d42351d39..c04e5ebfdf 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -30,7 +30,10 @@ export type GLOBAL_DIALOG_SCHEMA = { snapshotUrl: 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; 'enable-cloud': (props: { workspaceId: string; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 54d7979008..5fd462405b 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -12,7 +12,7 @@ "fr": 97, "hi": 1, "it": 94, - "ja": 93, + "ja": 92, "kk": 100, "ko": 93, "nb-NO": 46, @@ -20,9 +20,9 @@ "pt-BR": 92, "ru": 95, "sv-SE": 93, - "uk": 93, + "tr": 100, + "uk": 92, "ur": 100, "zh-Hans": 100, - "zh-Hant": 93, - "tr": 100 + "zh-Hant": 93 }