Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465import { 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), );}