Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273import { useEffect, useState } from 'react';import { decodeJwt } from 'jose';import * as client from 'openid-client';
export const LOGIN_NAME = 'Atmosphere';
const HABITAT_SERVER_ORIGIN = 'https://habitat-953995456319.us-west1.run.app';const HABITAT_CLIENT_ID = 'https://eagraf.github.io/client-metadata.json';const HABITAT_REDIRECT_URI = 'io.github.eagraf:/oauth';// TODO: Replace the broad default scope with the minimum ATProto permissions Textile actually needs.const HABITAT_SCOPE = '';const AUTH_INFO_STORAGE_KEY = 'habitat-auth-info';const AUTH_FLOW_STORAGE_KEY = 'habitat-auth-flow';
export type HabitatAuthInfo = { accessToken: string; did: string; expiresAt: number; handle: string; refreshToken?: string;};
type PendingAuthFlow = { codeVerifier: string; handle: string; state: string;};
function createHabitatConfig() { return new client.Configuration( { issuer: `${HABITAT_SERVER_ORIGIN}/oauth/authorize`, authorization_endpoint: `${HABITAT_SERVER_ORIGIN}/oauth/authorize`, token_endpoint: `${HABITAT_SERVER_ORIGIN}/oauth/token`, }, HABITAT_CLIENT_ID, );}
function readStoredAuthInfo(): HabitatAuthInfo | undefined { if (typeof window === 'undefined') return undefined; const raw = window.localStorage.getItem(AUTH_INFO_STORAGE_KEY); if (!raw) return undefined;
try { return JSON.parse(raw) as HabitatAuthInfo; } catch { window.localStorage.removeItem(AUTH_INFO_STORAGE_KEY); return undefined; }}
function readPendingFlow(): PendingAuthFlow | undefined { if (typeof window === 'undefined') return undefined; const raw = window.localStorage.getItem(AUTH_FLOW_STORAGE_KEY); if (!raw) return undefined;
try { return JSON.parse(raw) as PendingAuthFlow; } catch { window.localStorage.removeItem(AUTH_FLOW_STORAGE_KEY); return undefined; }}
class HabitatAuthManager { private authInfo = readStoredAuthInfo(); private config = createHabitatConfig(); private listeners = new Set<() => void>(); private refreshPromise: Promise<void> | undefined;
subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
getAuthInfo() { return this.authInfo; }
async login(accountInput: string) { const normalizedAccount = accountInput.trim(); if (!normalizedAccount) { throw new Error(`Please enter your ${LOGIN_NAME} Account.`); }
if (!window.textile?.startHabitatLogin) { throw new Error( `${LOGIN_NAME} Account sign-in is unavailable in this environment.`, ); }
const codeVerifier = client.randomPKCECodeVerifier(); const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); const state = client.randomState();
window.localStorage.setItem( AUTH_FLOW_STORAGE_KEY, JSON.stringify({ codeVerifier, handle: normalizedAccount, state, } satisfies PendingAuthFlow), );
const redirectUrl = client.buildAuthorizationUrl(this.config, { redirect_uri: HABITAT_REDIRECT_URI, response_type: 'code', scope: HABITAT_SCOPE, handle: normalizedAccount, state, code_challenge: codeChallenge, code_challenge_method: 'S256', });
const callbackUrl = await window.textile.startHabitatLogin( redirectUrl.toString(), HABITAT_REDIRECT_URI, );
await this.exchangeCallback(callbackUrl); }
logout = () => { this.authInfo = undefined; if (typeof window !== 'undefined') { window.localStorage.removeItem(AUTH_INFO_STORAGE_KEY); window.localStorage.removeItem(AUTH_FLOW_STORAGE_KEY); } this.emit(); };
async fetch( path: string, init?: RequestInit, headers?: Headers, options?: client.DPoPOptions, ) { // TODO: Replace openid-client fetchProtectedResource with a proper ATProto / XRPC // client (typed lexicons, list/get helpers, pagination, DPoP, error mapping). const authInfo = await this.getFreshAuthInfo(); if (!authInfo) { throw new Error(`Not signed in to ${LOGIN_NAME} Account.`); }
const requestHeaders = headers ?? new Headers(init?.headers); requestHeaders.set('Habitat-Auth-Method', 'oauth');
return client.fetchProtectedResource( this.config, authInfo.accessToken, new URL(path, HABITAT_SERVER_ORIGIN), init?.method ?? 'GET', init?.body as client.FetchBody | undefined, requestHeaders, options, ); }
private async exchangeCallback(callbackUrl: string) { const flow = readPendingFlow(); if (!flow) { throw new Error('Login session expired. Please try again.'); }
const url = new URL(callbackUrl); const oauthError = url.searchParams.get('error'); if (oauthError) { const description = url.searchParams.get('error_description') ?? oauthError; throw new Error(description); }
const token = await client.authorizationCodeGrant(this.config, url, { pkceCodeVerifier: flow.codeVerifier, expectedState: flow.state, });
window.localStorage.removeItem(AUTH_FLOW_STORAGE_KEY); this.setAuthState(token, flow.handle); }
private async getFreshAuthInfo() { const authInfo = this.authInfo; if (!authInfo) return undefined;
if ( authInfo.refreshToken && authInfo.expiresAt < Date.now() / 1000 + 5 * 60 ) { if (!this.refreshPromise) { this.refreshPromise = (async () => { const currentInfo = this.authInfo; if ( !currentInfo?.refreshToken || currentInfo.expiresAt >= Date.now() / 1000 + 5 * 60 ) { return; }
const token = await client.refreshTokenGrant( this.config, currentInfo.refreshToken, );
if (this.authInfo) { this.setAuthState(token, currentInfo.handle); } })().finally(() => { this.refreshPromise = undefined; }); }
await this.refreshPromise; }
return this.authInfo; }
private setAuthState( token: client.TokenEndpointResponse, handle: string, ): HabitatAuthInfo { const decoded = decodeJwt(token.access_token); if (!decoded.sub || !decoded.exp) { throw new Error('Invalid access token.'); }
const nextAuthInfo: HabitatAuthInfo = { accessToken: token.access_token, did: decoded.sub, expiresAt: decoded.exp, handle, refreshToken: token.refresh_token, };
this.authInfo = nextAuthInfo; window.localStorage.setItem( AUTH_INFO_STORAGE_KEY, JSON.stringify(nextAuthInfo), ); this.emit(); return nextAuthInfo; }
private emit() { for (const listener of this.listeners) { listener(); } }}
export const habitatAuth = new HabitatAuthManager();
export function useHabitatAuth() { const [authInfo, setAuthInfo] = useState<HabitatAuthInfo | undefined>(() => habitatAuth.getAuthInfo(), );
useEffect( () => habitatAuth.subscribe(() => setAuthInfo(habitatAuth.getAuthInfo())), [], );
return { authInfo, login: (accountInput: string) => habitatAuth.login(accountInput), logout: habitatAuth.logout, };}