import { getServiceEndpointForHandle } from "/js/atproto.js"; import { OauthClient, HandleNotFoundError, InvalidAuthUrlError, } from "/js/oauth.js"; import { isDev, isNative, TimeoutError, withTimeout } from "/js/utils.js"; import { linkToLogin, validateReturnToParam } from "/js/navigation.js"; export class RefreshTokenError extends Error { constructor(res) { super("Refresh token error"); this.res = res; } } export class InvalidUsernameError extends Error { constructor(message) { super(message); this.name = "InvalidUsernameError"; } } export class AuthError extends Error { constructor(message) { super(message); this.name = "AuthError"; } } export function getLoginErrorMessage(error) { if (error instanceof TimeoutError) return "Request timed out"; if (error instanceof InvalidUsernameError) return "Invalid username"; if (error instanceof AuthError) return "Authorization failed"; console.error(error); return "Failed to sign in"; } function parseJwt(token) { try { // Split the token into its three parts const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid JWT format"); } // Decode the payload (second part) const base64Url = parts[1]; const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const jsonPayload = decodeURIComponent( atob(base64) .split("") .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) .join(""), ); return JSON.parse(jsonPayload); } catch (error) { console.error("Failed to decode JWT:", error); return null; } } export class BasicAuthSession { constructor(accessJwt, refreshJwt) { this.accessJwt = accessJwt; this.refreshJwt = refreshJwt; } save() { localStorage.setItem("accessJwt", this.accessJwt); localStorage.setItem("refreshJwt", this.refreshJwt); } get serviceEndpoint() { // Decode the accessJwt to get the serviceEndpoint const decoded = parseJwt(this.accessJwt); return decoded.aud.replace("did:web:", "https://"); } get did() { const decoded = parseJwt(this.accessJwt); return decoded.sub; } get handle() { return null; } async fetch(url, options) { const res = await fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${this.accessJwt}`, }, }); // refresh the token if needed if (res.status === 400) { const error = await res.json(); if (error.error === "ExpiredToken") { const refreshRes = await fetch( this.serviceEndpoint + "/xrpc/com.atproto.server.refreshSession", { method: "POST", headers: { Authorization: `Bearer ${this.refreshJwt}`, }, }, ); if (refreshRes.ok) { const data = await refreshRes.json(); this.accessJwt = data.accessJwt; this.refreshJwt = data.refreshJwt; this.save(); return await this.fetch(url, options); } else { throw new RefreshTokenError(refreshRes); } } } return res; } async delete() { localStorage.removeItem("accessJwt"); localStorage.removeItem("refreshJwt"); } static fromLocalStorage() { const accessJwt = localStorage.getItem("accessJwt"); const refreshJwt = localStorage.getItem("refreshJwt"); if (!accessJwt || !refreshJwt) { return null; } return new BasicAuthSession(accessJwt, refreshJwt); } } export class BasicAuthProvider { constructor({ identityResolver } = {}) { this.session = null; this._loaded = false; this.identityResolver = identityResolver; } async getSession(did = null) { if (!this._loaded) { this.session = BasicAuthSession.fromLocalStorage(); this._loaded = true; } if (did !== null && this.session?.did !== did) return null; return this.session; } async login({ handle, password }) { const serviceEndpoint = await getServiceEndpointForHandle( handle, this.identityResolver, ); const res = await fetch( serviceEndpoint + "/xrpc/com.atproto.server.createSession", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ identifier: handle, password }), }, ); if (!res.ok) { throw new Error("Login failed"); } const data = await res.json(); const session = new BasicAuthSession(data.accessJwt, data.refreshJwt); this.session = session; this._loaded = true; this.session.save(); return session; } async logout(did = null) { const session = await this.getSession(); if (!session) return; await session.delete(); this.session = null; } async listAccounts() { const session = await this.getSession(); if (!session) return []; return [ { did: session.did, handle: session.handle, pdsUrl: null, needsReauth: false, }, ]; } supportsMultipleAccounts() { return false; } } export class OAuthProvider { constructor({ identityResolver } = {}) { this._client = null; this.identityResolver = identityResolver; } async getClient() { if (!this._client) { this._client = await OauthClient.load({ clientId: `https://${window.env.hostName}/oauth-client-metadata.json`, redirectUri: `https://${window.env.hostName}/callback.html`, identityResolver: this.identityResolver, }); } return this._client; } async getSession(did = null) { const client = await this.getClient(); return client.getSession(did); } async login({ handle, returnTo }) { const client = await this.getClient(); let authUrl = null; try { authUrl = await client.getAuthorizationUrl(handle, { scope: window.env.oauthScopes, state: { loopback: isDev(), returnTo: returnTo ?? null }, }); } catch (error) { if (error instanceof HandleNotFoundError) { throw new InvalidUsernameError("Invalid username"); } else if (error instanceof InvalidAuthUrlError) { throw new AuthError("Invalid authorization URL: " + error.message); } throw error; } window.location.href = authUrl; return new Promise(() => {}); // no resolve, just wait for redirect } async logout(did = null) { const client = await this.getClient(); await client.revoke(did); } async listAccounts() { const client = await this.getClient(); return client.listAccounts(); } async switchToAccount(did) { const client = await this.getClient(); client.switchToAccount(did); } async removeAccount(did) { const client = await this.getClient(); await client.removeAccount(did); } supportsMultipleAccounts() { return true; } async handleOauthCallback(url) { const params = new URLSearchParams(url.split("?")[1]); const code = params.get("code"); const state = params.get("state"); const iss = params.get("iss"); if (!code || !state || !iss) { throw new Error("Missing code, state, or iss in callback"); } const client = await this.getClient(); return await client.handleCallback({ code, state, iss }); } } const FORCE_LOGOUT_QUERY_PARAM = "force-logout"; export function getMissingScopes( grantedScope, requiredScope, optionalScope = "", ) { const granted = new Set(grantedScope.split(/\s+/).filter(Boolean)); const optional = new Set(optionalScope.split(/\s+/).filter(Boolean)); return requiredScope .split(/\s+/) .filter(Boolean) .filter((scope) => !granted.has(scope) && !optional.has(scope)); } export class Auth { constructor(provider) { if (!provider) { throw new Error("Auth requires a provider"); } this.provider = provider; } getSession(did = null) { return this.provider.getSession(did); } logout(did = null) { return this.provider.logout(did); } async login(args, { timeout = 10000 } = {}) { const call = () => this.provider.login(args); if (timeout) { return withTimeout(call, timeout); } return call(); } supportsMultipleAccounts() { return this.provider.supportsMultipleAccounts?.() ?? false; } async listAccounts() { const accounts = (await this.provider.listAccounts?.()) ?? []; const requiredScope = window.env?.oauthScopes ?? ""; const optionalScope = window.env?.oauthOptionalScopes ?? ""; // Mark accounts with out-of-date scopes as "needsReauth" return accounts.map((account) => { const scopesOutOfDate = account.scope != null && getMissingScopes(account.scope, requiredScope, optionalScope).length > 0; return { ...account, needsReauth: account.needsReauth || scopesOutOfDate, }; }); } async switchAccount(did) { if (!this.provider.switchToAccount) { throw new Error("Account switching is not supported"); } await this.provider.switchToAccount(did); window.location.reload(); return new Promise(() => {}); } // Drop a stored account. If removing the current one, flip to another // account first (or fall back to a full logout) and reload. async removeAccount(did) { if (!this.provider.removeAccount) { throw new Error("Account removal is not supported"); } const accounts = await this.listAccounts(); const current = await this.provider.getSession(); const isCurrent = current?.did === did; if (isCurrent) { const other = accounts.find((account) => account.did !== did); if (other) { await this.provider.switchToAccount(other.did); await this.provider.removeAccount(did); window.location.reload(); return new Promise(() => {}); } await this.provider.removeAccount(did); window.location.href = linkToLogin(); return new Promise(() => {}); } await this.provider.removeAccount(did); } async requireAuth() { const session = await this.provider.getSession(); if (!session) { window.location.href = linkToLogin(); return new Promise(() => {}); } return session; } async requireNoAuth() { const params = new URLSearchParams(window.location.search); // When adding an account, allow the login screen to render even though a // session already exists. The OAuth callback will flip the active account // to the newly-added one. if (params.get("addAccount") === "1") { return null; } const session = await this.provider.getSession(); if (session) { const returnTo = validateReturnToParam(params.get("returnTo")); window.location.href = returnTo ?? "/"; return new Promise(() => {}); } return null; } // Check for scope, ignoring query params async hasScope(scope) { const session = await this.provider.getSession(); if (!session) return false; if (!session.scope) return true; return session.scope .split(/\s+/) .some((granted) => granted === scope || granted.startsWith(scope + "?")); } async ensureCurrentScopes() { const session = await this.provider.getSession(); if (!session?.scope) return; const missing = getMissingScopes( session.scope, window.env.oauthScopes, window.env.oauthOptionalScopes ?? "", ); if (missing.length === 0) return; console.warn("OAuth scopes are out of date, forcing re-auth:", missing); try { await this.logout(); } catch (error) { console.error(error); } window.location.href = linkToLogin(); return new Promise(() => {}); } async handleForceLogoutParam() { const params = new URLSearchParams(window.location.search); if (!params.has(FORCE_LOGOUT_QUERY_PARAM)) { return; } try { await this.logout(); } catch (error) { console.error(error); } // Strip the param before building returnTo so login doesn't redirect back into a logout loop params.delete(FORCE_LOGOUT_QUERY_PARAM); const cleanSearch = params.size ? "?" + params.toString() : ""; window.history.replaceState( null, "", window.location.pathname + cleanSearch + window.location.hash, ); window.location.href = linkToLogin(); return new Promise(() => {}); } } export function createAuth({ identityResolver }) { const providerOptions = { identityResolver }; return new Auth( isNative() ? new BasicAuthProvider(providerOptions) : new OAuthProvider(providerOptions), ); }