Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

Make auth more friendly for SPAs

+42 -6
+1 -1
apps/aqua/src/auth/client.ts
··· 15 15 ? `${url}/client-metadata.json` 16 16 : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc("atproto transition:generic")}`, 17 17 client_uri: url, 18 - redirect_uris: [`${url}/oauth/callback`], 18 + redirect_uris: [`${url}/oauth/callback`, `${url}/oauth/callback/app`], 19 19 scope: "atproto transition:generic", 20 20 grant_types: ["authorization_code", "refresh_token"], 21 21 response_types: ["code"],
+39 -3
apps/aqua/src/auth/router.ts
··· 8 8 import { setCookie } from "hono/cookie"; 9 9 import { env } from "@/lib/env"; 10 10 11 - export async function callback(c: TealContext) { 11 + const publicUrl = env.PUBLIC_URL; 12 + const redirectBase = publicUrl || `http://127.0.0.1:${env.PORT}`; 13 + 14 + export function generateState(prefix?: string) { 15 + const state = crypto.randomUUID(); 16 + return `${prefix}${prefix ? ":" : ""}${state}`; 17 + } 18 + 19 + const SPA_PREFIX = "a37d"; 20 + 21 + // /oauth/login?handle=teal.fm 22 + export async function login(c: TealContext) { 23 + const { handle, spa } = c.req.query(); 24 + if (!handle) { 25 + return Response.json({ error: "Missing handle" }); 26 + } 27 + const url = await atclient.authorize(handle, { 28 + scope: "atproto transition:generic", 29 + // state.appState in callback 30 + state: generateState(spa ? SPA_PREFIX : undefined), 31 + }); 32 + return c.json({ url }); 33 + } 34 + 35 + // Redirect to the app's callback URL. 36 + async function callbackToApp(c: TealContext) { 37 + const queries = c.req.query(); 38 + const params = new URLSearchParams(queries); 39 + return c.redirect(`${env.APP_URI}/oauth/callback?${params.toString()}`); 40 + } 41 + 42 + export async function callback(c: TealContext, isSpa: boolean = false) { 12 43 try { 13 44 const honoParams = c.req.query(); 14 45 console.log("params", honoParams); 15 46 const params = new URLSearchParams(honoParams); 16 - const { session } = await atclient.callback(params); 47 + 48 + const { session, state } = await atclient.callback(params); 49 + 50 + console.log("state", state); 17 51 18 52 const did = session.did; 19 53 ··· 42 76 maxAge: 60 * 60 * 24 * 365, 43 77 }); 44 78 45 - if (params.get("spa")) { 79 + if (isSpa) { 46 80 return c.json({ 47 81 provider: "atproto", 48 82 jwt: did, ··· 122 156 123 157 const app = new Hono<EnvWithCtx>(); 124 158 159 + app.get("/login", async (c) => login(c)); 125 160 app.get("/callback", async (c) => callback(c)); 161 + app.get("/callback/app", async (c) => callback(c, true)); 126 162 app.get("/refresh", async (c) => refresh(c)); 127 163 128 164 export const getAuthRouter = () => {
-1
apps/aqua/src/auth/storage.ts
··· 17 17 .where(eq(authState.key, key)) 18 18 .limit(1) 19 19 .execute(); 20 - console.log("getting state", key, result); 21 20 if (!result[0]) return; 22 21 return JSON.parse(result[0].state) as NodeSavedState; 23 22 }
+1
apps/aqua/src/lib/env.ts
··· 14 14 HOST: host({ devDefault: testOnly("0.0.0.0") }), 15 15 PORT: port({ devDefault: testOnly(3000) }), 16 16 PUBLIC_URL: str({}), 17 + APP_URI: str({ devDefault: "fm.teal.amethyst://" }), 17 18 DB_PATH: str({ devDefault: "file:./db.sqlite" }), 18 19 COOKIE_SECRET: str({ devDefault: "secret_cookie! very secret!" }), 19 20 });
bun.lockb

This is a binary file and will not be displayed.

+1 -1
package.json
··· 16 16 "db:seed": "drizzle-kit seed" 17 17 }, 18 18 "devDependencies": { 19 - "turbo": "^2.3.0" 19 + "turbo": "^2.3.1" 20 20 }, 21 21 "engines": { 22 22 "node": ">=20.0.0"