diff --git a/packages/data-center/src/provider/affine/affine.ts b/packages/data-center/src/provider/affine/affine.ts index d3f110950..0b28813ee 100644 --- a/packages/data-center/src/provider/affine/affine.ts +++ b/packages/data-center/src/provider/affine/affine.ts @@ -51,22 +51,22 @@ export class AffineProvider extends BaseProvider { } override async init() { - this._apis.token.onChange(() => { - if (this._apis.token.isLogin) { + this._apis.auth.onChange(() => { + if (this._apis.auth.isLogin) { this._reconnectChannel(); } else { this._destroyChannel(); } }); - if (this._apis.token.isExpired && this._apis.token.refresh) { + if (this._apis.auth.isExpired && this._apis.auth.refresh) { // do we need to await the following? - this._apis.token.refreshToken(); + this._apis.auth.refreshToken(); } } private _reconnectChannel() { - if (this._refreshToken !== this._apis.token.refresh) { + if (this._refreshToken !== this._apis.auth.refresh) { // need to reconnect this._destroyChannel(); @@ -77,7 +77,7 @@ export class AffineProvider extends BaseProvider { this._logger, { params: { - token: this._apis.token.refresh, + token: this._apis.auth.refresh, }, } ); @@ -86,7 +86,7 @@ export class AffineProvider extends BaseProvider { this._handlerAffineListMessage(msg); }); - this._refreshToken = this._apis.token.refresh; + this._refreshToken = this._apis.auth.refresh; } } @@ -170,13 +170,13 @@ export class AffineProvider extends BaseProvider { window.location.protocol === 'https:' ? 'wss' : 'ws' }://${window.location.host}/api/sync/`; ws = new WebsocketProvider(wsUrl, room, doc, { - params: { token: this._apis.token.refresh }, + params: { token: this._apis.auth.refresh }, // @ts-expect-error ignore the type awareness: workspace.awarenessStore.awareness, }); workspace.awarenessStore.awareness.setLocalStateField('user', { - name: this._apis.token.user?.name ?? 'other', - id: Number(this._apis.token.user?.id ?? -1), + name: this._apis.auth.user?.name ?? 'other', + id: Number(this._apis.auth.user?.id ?? -1), color: '#ffa500', }); @@ -226,7 +226,7 @@ export class AffineProvider extends BaseProvider { } override async loadWorkspaces() { - if (!this._apis.token.isLogin) { + if (!this._apis.auth.isLogin) { return []; } const workspacesList = await this._apis.getWorkspaces(); @@ -255,9 +255,9 @@ export class AffineProvider extends BaseProvider { } override async auth() { - if (this._apis.token.isLogin) { - await this._apis.token.refreshToken(); - if (this._apis.token.isLogin && !this._apis.token.isExpired) { + if (this._apis.auth.isLogin) { + await this._apis.auth.refreshToken(); + if (this._apis.auth.isLogin && !this._apis.auth.isExpired) { // login success return; } @@ -272,7 +272,7 @@ export class AffineProvider extends BaseProvider { // TODO: may need to update related workspace attributes on user info change? public override async getUserInfo(): Promise { - const user = this._apis.token.user; + const user = this._apis.auth.user; return user ? { id: user.id, @@ -382,7 +382,7 @@ export class AffineProvider extends BaseProvider { } public override getToken(): string { - return this._apis.token.token; + return this._apis.auth.token; } public override async getUserByEmail( @@ -434,10 +434,11 @@ export class AffineProvider extends BaseProvider { } public override async logout(): Promise { - this._apis.token.clear(); + this._apis.auth.clear(); this._destroyChannel(); this._wsMap.forEach(ws => ws.disconnect()); this._workspaces.clear(false); + await this._apis.signOutFirebase(); } public override async getWorkspaceMembers(id: string) { diff --git a/packages/data-center/src/provider/affine/apis/__tests__/token.spec.ts b/packages/data-center/src/provider/affine/apis/__tests__/auth.spec.ts similarity index 79% rename from packages/data-center/src/provider/affine/apis/__tests__/token.spec.ts rename to packages/data-center/src/provider/affine/apis/__tests__/auth.spec.ts index 739bae9e9..72a271c12 100644 --- a/packages/data-center/src/provider/affine/apis/__tests__/token.spec.ts +++ b/packages/data-center/src/provider/affine/apis/__tests__/auth.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from '@playwright/test'; -import { Token } from '../token.js'; +import { Auth } from '../auth.js'; -test.describe('class Token', () => { +test.describe('class Auth', () => { test('parse tokens', () => { const tokenString = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NzU2Nzk1MjAsImlkIjo2LCJuYW1lIjoidGVzdCIsImVtYWlsIjoidGVzdEBnbWFpbC5jb20iLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly90ZXN0LmNvbS9hdmF0YXIiLCJjcmVhdGVkX2F0IjoxNjc1Njc4OTIwMzU4fQ.R8GxrNhn3gNumtapthrP6_J5eQjXLV7i-LanSPqe7hw`; - expect(Token.parse(tokenString)).toEqual({ + expect(Auth.parseIdToken(tokenString)).toEqual({ avatar_url: 'https://test.com/avatar', created_at: 1675678920358, email: 'test@gmail.com', @@ -16,6 +16,6 @@ test.describe('class Token', () => { test('parse invalid tokens', () => { const tokenString = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.aaa.R8GxrNhn3gNumtapthrP6_J5eQjXLV7i-LanSPqe7hw`; - expect(Token.parse(tokenString)).toEqual(null); + expect(Auth.parseIdToken(tokenString)).toEqual(null); }); }); diff --git a/packages/data-center/src/provider/affine/apis/token.ts b/packages/data-center/src/provider/affine/apis/auth.ts similarity index 58% rename from packages/data-center/src/provider/affine/apis/token.ts rename to packages/data-center/src/provider/affine/apis/auth.ts index 5451ba1f9..cd79513f2 100644 --- a/packages/data-center/src/provider/affine/apis/token.ts +++ b/packages/data-center/src/provider/affine/apis/auth.ts @@ -1,5 +1,11 @@ import { initializeApp } from 'firebase/app'; -import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; +import { + type Auth as FirebaseAuth, + getAuth as getFirebaseAuth, + GoogleAuthProvider, + signInWithPopup, + signOut, +} from 'firebase/auth'; import type { User } from 'firebase/auth'; import { decode } from 'js-base64'; @@ -40,7 +46,7 @@ const AFFINE_LOGIN_STORAGE_KEY = 'affine:login'; const doLogin = (params: LoginParams): Promise => bareClient.post('api/user/token', { json: params }).json(); -export class Token { +export class Auth { private readonly _logger; private _accessToken = ''; // idtoken (JWT) private _refreshToken = ''; @@ -55,14 +61,10 @@ export class Token { this.restoreLogin(); } - get user() { - return this._user; - } - setLogin(login: LoginResponse) { this._accessToken = login.token; this._refreshToken = login.refresh; - this._user = Token.parse(this._accessToken); + this._user = Auth.parseIdToken(this._accessToken); this.triggerChange(this._user); this.storeLogin(); @@ -103,13 +105,22 @@ export class Token { type: 'Refresh', token: refreshToken || this._refreshToken, }); + this._padding.finally(() => { + // clear on settled + this._padding = undefined; + }); this._refreshToken = refreshToken || this._refreshToken; } const res = await this._padding; if (!refreshToken || refreshToken !== this._refreshToken) { this.setLogin(res); } - this._padding = undefined; + return true; + } + + get user() { + // computed through access token + return this._user; } get token() { @@ -130,7 +141,7 @@ export class Token { return Date.now() > this._user.exp * 1000; } - static parse(token: string): AccessTokenMessage | null { + static parseIdToken(token: string): AccessTokenMessage | null { try { return JSON.parse(decode(token.split('.')[1])); } catch (error) { @@ -166,55 +177,78 @@ export class Token { } } -export const token = new Token(); +export const auth = new Auth(); export const getAuthorizer = () => { - const app = initializeApp({ - apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, - authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, - measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, - }); - try { - const firebaseAuth = getAuth(app); + let _firebaseAuth: FirebaseAuth | null = null; - const googleAuthProvider = new GoogleAuthProvider(); - - const getToken = async () => { - const currentUser = firebaseAuth.currentUser; - if (currentUser) { - await currentUser.getIdTokenResult(true); - if (!currentUser.isAnonymous) { - return currentUser.getIdToken(); - } + // getAuth will send requests on calling thus we can lazy init it + const getAuth = () => { + try { + if (!_firebaseAuth) { + const app = initializeApp({ + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: + process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, + measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, + }); + _firebaseAuth = getFirebaseAuth(app); } - return; - }; + return _firebaseAuth; + } catch (error) { + getLogger('getAuthorizer')(error); + console.error('getAuthorizer', error); + return null; + } + }; - const signInWithGoogle = async () => { - const idToken = await getToken(); - let loginUser: AccessTokenMessage | null = null; - if (idToken) { - loginUser = await token.initToken(idToken); - } else { + const getToken = async () => { + const currentUser = getAuth()?.currentUser; + if (currentUser) { + await currentUser.getIdTokenResult(true); + if (!currentUser.isAnonymous) { + return currentUser.getIdToken(); + } + } + return; + }; + + const signInWithGoogle = async () => { + const idToken = await getToken(); + let loginUser: AccessTokenMessage | null = null; + if (idToken) { + loginUser = await auth.initToken(idToken); + } else { + const firebaseAuth = getAuth(); + if (firebaseAuth) { + const googleAuthProvider = new GoogleAuthProvider(); + // make sure the user has a chance to select an account + // https://developers.google.com/identity/openid-connect/openid-connect#prompt + googleAuthProvider.setCustomParameters({ + prompt: 'select_account', + }); const user = await signInWithPopup(firebaseAuth, googleAuthProvider); const idToken = await user.user.getIdToken(); - loginUser = await token.initToken(idToken); + loginUser = await auth.initToken(idToken); } - return loginUser; - }; + } + return loginUser; + }; - const onAuthStateChanged = (callback: (user: User | null) => void) => { - firebaseAuth.onAuthStateChanged(callback); - }; + const onAuthStateChanged = (callback: (user: User | null) => void) => { + getAuth()?.onAuthStateChanged(callback); + }; - return [signInWithGoogle, onAuthStateChanged] as const; - } catch (e) { - getLogger('getAuthorizer')(e); - console.error('getAuthorizer', e); - return [] as const; - } + const signOutFirebase = async () => { + const firebaseAuth = getAuth(); + if (firebaseAuth?.currentUser) { + await signOut(firebaseAuth); + } + }; + + return [signInWithGoogle, onAuthStateChanged, signOutFirebase] as const; }; diff --git a/packages/data-center/src/provider/affine/apis/index.ts b/packages/data-center/src/provider/affine/apis/index.ts index 27eebb513..d96fc07c5 100644 --- a/packages/data-center/src/provider/affine/apis/index.ts +++ b/packages/data-center/src/provider/affine/apis/index.ts @@ -1,10 +1,10 @@ // export { token } from './token.js'; -export type { Callback } from './token.js'; +export type { Callback } from './auth.js'; -import { getAuthorizer } from './token.js'; +import { getAuthorizer } from './auth.js'; import * as user from './user.js'; import * as workspace from './workspace.js'; -import { token } from './token.js'; +import { auth } from './auth.js'; // See https://twitter.com/mattpocockuk/status/1622730173446557697 // TODO: move to ts utils? @@ -18,20 +18,23 @@ export type Apis = Prettify< Omit & { signInWithGoogle: ReturnType[0]; onAuthStateChanged: ReturnType[1]; - } & { token: typeof token } + signOutFirebase: ReturnType[2]; + } & { auth: typeof auth } >; export const getApis = (): Apis => { - const [signInWithGoogle, onAuthStateChanged] = getAuthorizer(); + const [signInWithGoogle, onAuthStateChanged, signOutFirebase] = + getAuthorizer(); return { ...user, ...workspace, signInWithGoogle, + signOutFirebase, onAuthStateChanged, - token, + auth, }; }; -export type { AccessTokenMessage } from './token'; +export type { AccessTokenMessage } from './auth'; export type { Member, Workspace, WorkspaceDetail } from './workspace'; export { WorkspaceType } from './workspace.js'; diff --git a/packages/data-center/src/provider/affine/apis/request.ts b/packages/data-center/src/provider/affine/apis/request.ts index 29f90e19d..b0a758468 100644 --- a/packages/data-center/src/provider/affine/apis/request.ts +++ b/packages/data-center/src/provider/affine/apis/request.ts @@ -1,6 +1,6 @@ import ky from 'ky-universal'; import { MessageCenter } from '../../../message/index.js'; -import { token } from './token.js'; +import { auth } from './auth.js'; type KyInstance = typeof ky; @@ -28,13 +28,23 @@ export const bareClient: KyInstance = ky.extend({ }, }); +const refreshTokenIfExpired = async () => { + if (auth.isLogin && auth.isExpired) { + try { + await auth.refreshToken(); + } catch (err) { + return new Response('Unauthorized', { status: 401 }); + } + } +}; + export const client: KyInstance = bareClient.extend({ hooks: { beforeRequest: [ async request => { - if (token.isLogin) { - if (token.isExpired) await token.refreshToken(); - request.headers.set('Authorization', token.token); + if (auth.isLogin) { + await refreshTokenIfExpired(); + request.headers.set('Authorization', auth.token); } else { return new Response('Unauthorized', { status: 401 }); } @@ -43,9 +53,8 @@ export const client: KyInstance = bareClient.extend({ beforeRetry: [ async ({ request }) => { - console.log('beforeRetry'); - await token.refreshToken(); - request.headers.set('Authorization', token.token); + await refreshTokenIfExpired(); + request.headers.set('Authorization', auth.token); }, ], diff --git a/packages/data-center/src/provider/affine/apis/workspace.ts b/packages/data-center/src/provider/affine/apis/workspace.ts index 584db159f..c12fed6e6 100644 --- a/packages/data-center/src/provider/affine/apis/workspace.ts +++ b/packages/data-center/src/provider/affine/apis/workspace.ts @@ -41,7 +41,7 @@ export interface Workspace { export async function getWorkspaces(): Promise { try { - return client + return await client .get('api/workspace', { headers: { 'Cache-Control': 'no-cache', @@ -63,8 +63,7 @@ export async function getWorkspaceDetail( params: GetWorkspaceDetailParams ): Promise { try { - const response = client.get(`api/workspace/${params.id}`); - return response.json(); + return await client.get(`api/workspace/${params.id}`).json(); } catch (error) { sendMessage(messageCode.getDetailFailed); throw new RequestError('get detail failed', error); @@ -100,7 +99,7 @@ export async function getWorkspaceMembers( params: GetWorkspaceDetailParams ): Promise { try { - return client.get(`api/workspace/${params.id}/permission`).json(); + return await client.get(`api/workspace/${params.id}/permission`).json(); } catch (error) { sendMessage(messageCode.getMembersFailed); throw new RequestError('get members failed', error); @@ -115,7 +114,7 @@ export async function createWorkspace( encodedYDoc: Blob ): Promise<{ id: string }> { try { - return client.post('api/workspace', { body: encodedYDoc }).json(); + return await client.post('api/workspace', { body: encodedYDoc }).json(); } catch (error) { sendMessage(messageCode.createWorkspaceFailed); throw new RequestError('create workspace failed', error); @@ -131,7 +130,7 @@ export async function updateWorkspace( params: UpdateWorkspaceParams ): Promise<{ public: boolean | null }> { try { - return client + return await client .post(`api/workspace/${params.id}`, { json: { public: params.public, @@ -151,10 +150,12 @@ export interface DeleteWorkspaceParams { export async function deleteWorkspace( params: DeleteWorkspaceParams ): Promise { - await client.delete(`api/workspace/${params.id}`).catch(error => { + try { + await client.delete(`api/workspace/${params.id}`); + } catch (error) { sendMessage(messageCode.deleteWorkspaceFailed); throw new RequestError('delete workspace failed', error); - }); + } } export interface InviteMemberParams { @@ -167,13 +168,11 @@ export interface InviteMemberParams { */ export async function inviteMember(params: InviteMemberParams): Promise { try { - return client - .post(`api/workspace/${params.id}/permission`, { - json: { - email: params.email, - }, - }) - .json(); + await client.post(`api/workspace/${params.id}/permission`, { + json: { + email: params.email, + }, + }); } catch (error) { sendMessage(messageCode.inviteMemberFailed); throw new RequestError('invite member failed', error); @@ -185,10 +184,12 @@ export interface RemoveMemberParams { } export async function removeMember(params: RemoveMemberParams): Promise { - await client.delete(`api/permission/${params.permissionId}`).catch(error => { + try { + await client.delete(`api/permission/${params.permissionId}`); + } catch (error) { sendMessage(messageCode.removeMemberFailed); throw new RequestError('remove member failed', error); - }); + } } export interface AcceptInvitingParams { @@ -199,7 +200,9 @@ export async function acceptInviting( params: AcceptInvitingParams ): Promise { try { - return bareClient.post(`api/invitation/${params.invitingCode}`).json(); + return await bareClient + .post(`api/invitation/${params.invitingCode}`) + .json(); } catch (error) { sendMessage(messageCode.acceptInvitingFailed); throw new RequestError('accept inviting failed', error); @@ -214,7 +217,7 @@ export async function getBlob(params: { blobId: string; }): Promise { try { - return client.get(`api/blob/${params.blobId}`).arrayBuffer(); + return await client.get(`api/blob/${params.blobId}`).arrayBuffer(); } catch (error) { sendMessage(messageCode.getBlobFailed); throw new RequestError('get blob failed', error); @@ -226,13 +229,12 @@ export interface LeaveWorkspaceParams { } export async function leaveWorkspace({ id }: LeaveWorkspaceParams) { - await client - .delete(`api/workspace/${id}/permission`) - .json() - .catch(error => { - sendMessage(messageCode.leaveWorkspaceFailed); - throw new RequestError('leave workspace failed', error); - }); + try { + await client.delete(`api/workspace/${id}/permission`); + } catch (error) { + sendMessage(messageCode.leaveWorkspaceFailed); + throw new RequestError('leave workspace failed', error); + } } export async function downloadWorkspace( @@ -241,9 +243,11 @@ export async function downloadWorkspace( ): Promise { try { if (published) { - return bareClient.get(`api/public/doc/${workspaceId}`).arrayBuffer(); + return await bareClient + .get(`api/public/doc/${workspaceId}`) + .arrayBuffer(); } - return client.get(`api/workspace/${workspaceId}/doc`).arrayBuffer(); + return await client.get(`api/workspace/${workspaceId}/doc`).arrayBuffer(); } catch (error) { sendMessage(messageCode.downloadWorkspaceFailed); throw new RequestError('download workspace failed', error); diff --git a/packages/data-center/src/provider/affine/channel.ts b/packages/data-center/src/provider/affine/channel.ts index da9e81c35..ce19a35ac 100644 --- a/packages/data-center/src/provider/affine/channel.ts +++ b/packages/data-center/src/provider/affine/channel.ts @@ -1,6 +1,6 @@ import * as websocket from 'lib0/websocket'; import { Logger } from 'src/types'; -import { token } from './apis/token'; +import { auth } from './apis/auth'; import * as url from 'lib0/url'; const RECONNECT_INTERVAL_TIME = 500; @@ -40,7 +40,7 @@ export class WebsocketClient extends websocket.WebsocketClient { this.on('disconnect', ({ error }: { error: Error }) => { if (error) { // Try reconnect if connect error has occurred - if (this.shouldReconnect && token.isLogin && !this.connected) { + if (this.shouldReconnect && auth.isLogin && !this.connected) { try { setTimeout(() => { if (this._retryTimes <= MAX_RECONNECT_TIMES) {