this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

oauth: switch to @atcute node oauth

- pub/priv oauth clients

Clément dc6ea253 2494a9e3

+334 -285
+4 -2
.github/workflows/frontend.yml
··· 128 128 workingDirectory: app 129 129 secrets: | 130 130 SESSION_SECRET 131 - ENCRYPTION_KEY 131 + PRIVATE_KEY_JWK 132 + BASE_URL 132 133 env: 133 134 SESSION_SECRET: ${{ secrets.SESSION_SECRET }} 134 - ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} 135 + PRIVATE_KEY_JWK: "" # TODO 136 + BASE_URL: "https://compiles.at"
+2 -1
app/.env.example
··· 1 1 SESSION_SECRET= 2 - ENCRYPTION_KEY= 2 + PRIVATE_KEY_JWK= 3 + BASE_URL=http://127.0.0.1:5173 3 4 4 5 JUDGE0_BASE_URL=https://example.com 5 6 JUDGE0_AUTHN_HEADER=X-Auth-Token
+20
app/migrations/0005_server_oauth.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "OAuthSession" ( 3 + "did" TEXT NOT NULL PRIMARY KEY, 4 + "dpopKey" TEXT NOT NULL, 5 + "authMethod" TEXT NOT NULL, 6 + "tokenSet" TEXT NOT NULL, 7 + "updatedAt" DATETIME NOT NULL, 8 + CONSTRAINT "OAuthSession_did_fkey" FOREIGN KEY ("did") REFERENCES "User" ("did") ON DELETE CASCADE ON UPDATE CASCADE 9 + ); 10 + 11 + -- CreateTable 12 + CREATE TABLE "OAuthState" ( 13 + "stateId" TEXT NOT NULL PRIMARY KEY, 14 + "data" TEXT NOT NULL, 15 + "expiresAt" DATETIME NOT NULL 16 + ); 17 + 18 + -- RedefineTables 19 + DELETE FROM "Session"; 20 + ALTER TABLE "Session" DROP COLUMN "atProtoSessionData";
+2 -2
app/package.json
··· 22 22 "@atcute/client": "^4.2.1", 23 23 "@atcute/identity-resolver": "^1.2.2", 24 24 "@atcute/lexicons": "^1.3.0", 25 - "@atcute/oauth-browser-client": "^3.0.0", 26 - "@atproto/jwk": "^0.6.0", 25 + "@atcute/oauth-node-client": "^1.1.0", 27 26 "@cloudflare/vite-plugin": "^1.35.0", 28 27 "@codemirror/lang-python": "^6.2.1", 29 28 "@codemirror/language": "^6.12.3", ··· 53 52 "prettier": "^3.8.3", 54 53 "prettier-plugin-tailwindcss": "^0.8.0", 55 54 "prisma": "7.8.0", 55 + "radashi": "^12.9.0", 56 56 "solid-js": "^1.9.12", 57 57 "tailwindcss": "^4.2.4", 58 58 "thememirror": "^2.0.1",
+24 -9
app/prisma/schema.prisma
··· 9 9 } 10 10 11 11 model User { 12 - did String @id // This has to be validated as a correctly formated DID 13 - sessions Session[] 14 - whitelisted Boolean @default(false) 12 + did String @id // This has to be validated as a correctly formated DID 13 + sessions Session[] 14 + oauthSession OAuthSession? 15 + whitelisted Boolean @default(false) 15 16 } 16 17 17 18 model Session { 18 - id String @id @default(uuid()) 19 - userDid String 20 - user User @relation(fields: [userDid], references: [did]) 21 - atProtoSessionData String 22 - token String @unique 23 - createdAt DateTime @default(now()) 19 + id String @id @default(uuid()) 20 + userDid String 21 + user User @relation(fields: [userDid], references: [did]) 22 + token String @unique 23 + createdAt DateTime @default(now()) 24 + } 25 + 26 + model OAuthSession { 27 + did String @id 28 + user User @relation(fields: [did], references: [did], onDelete: Cascade) 29 + dpopKey String 30 + authMethod String 31 + tokenSet String 32 + updatedAt DateTime @updatedAt 33 + } 34 + 35 + model OAuthState { 36 + stateId String @id 37 + data String 38 + expiresAt DateTime 24 39 }
+4
app/scripts/generate-key.ts
··· 1 + import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 2 + 3 + const jwk = await generateClientAssertionKey('main', 'ES256'); 4 + process.stdout.write(JSON.stringify(jwk));
+8 -7
app/scripts/generate-metadata.ts
··· 2 2 import { dirname } from 'node:path'; 3 3 import { fileURLToPath } from 'node:url'; 4 4 5 + import { buildOAuthClientMetadata } from '../src/lib/oauth-metadata.ts'; 6 + 5 7 const __filename = fileURLToPath(import.meta.url); 6 8 const __dirname = dirname(__filename); 7 9 ··· 10 12 writeFileSync( 11 13 `${__dirname}/../public/oauth-client-metadata.json`, 12 14 JSON.stringify({ 13 - client_id: `${baseUrl}/oauth-client-metadata.json`, 14 - client_uri: baseUrl, 15 - redirect_uris: [`${baseUrl}/oauth/callback`], 16 - scope: 'atproto transition:generic', 17 - application_type: 'web', 18 - token_endpoint_auth_method: 'none', 19 - grant_types: ['authorization_code'], 15 + ...buildOAuthClientMetadata(baseUrl), 16 + application_type: 'web' as const, 17 + token_endpoint_auth_method: 'private_key_jwt' as const, 18 + token_endpoint_auth_signing_alg: 'ES256', 19 + grant_types: ['authorization_code', 'refresh_token'] as const, 20 + response_types: ['code'] as const, 20 21 dpop_bound_access_tokens: true, 21 22 }), 22 23 );
-33
app/src/lib/auth/oauth-config.ts
··· 1 - import { 2 - CompositeDidDocumentResolver, 3 - LocalActorResolver, 4 - PlcDidDocumentResolver, 5 - WebDidDocumentResolver, 6 - XrpcHandleResolver, 7 - } from '@atcute/identity-resolver'; 8 - import { configureOAuth } from '@atcute/oauth-browser-client'; 9 - 10 - const redirect_uri = import.meta.env.VITE_OAUTH_REDIRECT_URL; 11 - const client_id = import.meta.env.DEV 12 - ? `http://localhost?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(import.meta.env.VITE_OAUTH_SCOPE)}` 13 - : `${import.meta.env.VITE_OAUTH_CLIENT_URI}/oauth-client-metadata.json`; 14 - 15 - if (!import.meta.env.SSR) { 16 - configureOAuth({ 17 - metadata: { 18 - client_id, 19 - redirect_uri, 20 - }, 21 - identityResolver: new LocalActorResolver({ 22 - handleResolver: new XrpcHandleResolver({ 23 - serviceUrl: 'https://public.api.bsky.app', 24 - }), 25 - didDocumentResolver: new CompositeDidDocumentResolver({ 26 - methods: { 27 - plc: new PlcDidDocumentResolver(), 28 - web: new WebDidDocumentResolver(), 29 - }, 30 - }), 31 - }), 32 - }); 33 - }
+6 -9
app/src/lib/auth/scope-flow.ts
··· 1 1 import type { Did, Handle } from '@atcute/lexicons/syntax'; 2 - import { createAuthorizationUrl } from '@atcute/oauth-browser-client'; 2 + import { useServerFn } from '@tanstack/solid-start'; 3 3 4 - import './oauth-config'; 4 + import { startAuthorizationFn } from '~/server/oauth'; 5 5 6 6 type UseOAuthScopeFlowOptions = { 7 7 onError?: (error: unknown) => void; ··· 9 9 }; 10 10 11 11 export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => { 12 + const startAuthorization = useServerFn(startAuthorizationFn); 13 + 12 14 const connect = async (identifier: Handle | Did) => { 13 15 try { 14 - const authUrl = await createAuthorizationUrl({ 15 - scope: import.meta.env.VITE_OAUTH_SCOPE, 16 - target: { 17 - type: 'account', 18 - identifier, 19 - }, 20 - }); 16 + const { url } = await startAuthorization({ data: { identifier } }); 17 + const authUrl = new URL(url); 21 18 22 19 if (options.beforeRedirect) { 23 20 await options.beforeRedirect(identifier, authUrl);
+15
app/src/lib/oauth-metadata.ts
··· 1 + import type { ConfidentialClientMetadata } from '@atcute/oauth-node-client'; 2 + 3 + export const OAUTH_SCOPE = 'atproto transition:generic'; 4 + 5 + export function buildOAuthClientMetadata( 6 + baseUrl: string, 7 + ): ConfidentialClientMetadata { 8 + return { 9 + client_id: `${baseUrl}/oauth-client-metadata.json`, 10 + client_uri: baseUrl, 11 + redirect_uris: [`${baseUrl}/oauth/callback`], 12 + scope: OAUTH_SCOPE, 13 + jwks_uri: `${baseUrl}/oauth/jwks`, 14 + }; 15 + }
+21
app/src/routeTree.gen.ts
··· 14 14 import { Route as FriendsRouteImport } from './routes/friends' 15 15 import { Route as ExploreRouteImport } from './routes/explore' 16 16 import { Route as IndexRouteImport } from './routes/index' 17 + import { Route as OauthJwksRouteImport } from './routes/oauth/jwks' 17 18 import { Route as OauthCallbackRouteImport } from './routes/oauth/callback' 18 19 19 20 const SettingsRoute = SettingsRouteImport.update({ ··· 39 40 const IndexRoute = IndexRouteImport.update({ 40 41 id: '/', 41 42 path: '/', 43 + getParentRoute: () => rootRouteImport, 44 + } as any) 45 + const OauthJwksRoute = OauthJwksRouteImport.update({ 46 + id: '/oauth/jwks', 47 + path: '/oauth/jwks', 42 48 getParentRoute: () => rootRouteImport, 43 49 } as any) 44 50 const OauthCallbackRoute = OauthCallbackRouteImport.update({ ··· 54 60 '/leaderboard': typeof LeaderboardRoute 55 61 '/settings': typeof SettingsRoute 56 62 '/oauth/callback': typeof OauthCallbackRoute 63 + '/oauth/jwks': typeof OauthJwksRoute 57 64 } 58 65 export interface FileRoutesByTo { 59 66 '/': typeof IndexRoute ··· 62 69 '/leaderboard': typeof LeaderboardRoute 63 70 '/settings': typeof SettingsRoute 64 71 '/oauth/callback': typeof OauthCallbackRoute 72 + '/oauth/jwks': typeof OauthJwksRoute 65 73 } 66 74 export interface FileRoutesById { 67 75 __root__: typeof rootRouteImport ··· 71 79 '/leaderboard': typeof LeaderboardRoute 72 80 '/settings': typeof SettingsRoute 73 81 '/oauth/callback': typeof OauthCallbackRoute 82 + '/oauth/jwks': typeof OauthJwksRoute 74 83 } 75 84 export interface FileRouteTypes { 76 85 fileRoutesByFullPath: FileRoutesByFullPath ··· 81 90 | '/leaderboard' 82 91 | '/settings' 83 92 | '/oauth/callback' 93 + | '/oauth/jwks' 84 94 fileRoutesByTo: FileRoutesByTo 85 95 to: 86 96 | '/' ··· 89 99 | '/leaderboard' 90 100 | '/settings' 91 101 | '/oauth/callback' 102 + | '/oauth/jwks' 92 103 id: 93 104 | '__root__' 94 105 | '/' ··· 97 108 | '/leaderboard' 98 109 | '/settings' 99 110 | '/oauth/callback' 111 + | '/oauth/jwks' 100 112 fileRoutesById: FileRoutesById 101 113 } 102 114 export interface RootRouteChildren { ··· 106 118 LeaderboardRoute: typeof LeaderboardRoute 107 119 SettingsRoute: typeof SettingsRoute 108 120 OauthCallbackRoute: typeof OauthCallbackRoute 121 + OauthJwksRoute: typeof OauthJwksRoute 109 122 } 110 123 111 124 declare module '@tanstack/solid-router' { ··· 145 158 preLoaderRoute: typeof IndexRouteImport 146 159 parentRoute: typeof rootRouteImport 147 160 } 161 + '/oauth/jwks': { 162 + id: '/oauth/jwks' 163 + path: '/oauth/jwks' 164 + fullPath: '/oauth/jwks' 165 + preLoaderRoute: typeof OauthJwksRouteImport 166 + parentRoute: typeof rootRouteImport 167 + } 148 168 '/oauth/callback': { 149 169 id: '/oauth/callback' 150 170 path: '/oauth/callback' ··· 162 182 LeaderboardRoute: LeaderboardRoute, 163 183 SettingsRoute: SettingsRoute, 164 184 OauthCallbackRoute: OauthCallbackRoute, 185 + OauthJwksRoute: OauthJwksRoute, 165 186 } 166 187 export const routeTree = rootRouteImport 167 188 ._addFileChildren(rootRouteChildren)
+37 -86
app/src/routes/oauth/callback.tsx
··· 1 - import { finalizeAuthorization } from '@atcute/oauth-browser-client'; 2 - import type { Session } from '@atcute/oauth-browser-client'; 3 - import { createFileRoute, useNavigate } from '@tanstack/solid-router'; 4 - import { createServerFn } from '@tanstack/solid-start'; 1 + import { createFileRoute } from '@tanstack/solid-router'; 5 2 import crypto from 'node:crypto'; 6 - import { onMount } from 'solid-js'; 7 3 8 - import { useAuth } from '~/contexts/auth'; 9 - import { encrypt } from '~/server/crypto.server'; 10 4 import { prisma } from '~/server/db.server'; 5 + import { getOAuthClient } from '~/server/oauth-client.server'; 11 6 12 - import '$/auth/oauth-config'; 13 7 import { useAppSession } from '$/session'; 14 - 15 - import { toaster } from '@/Toaster'; 16 8 17 9 export const Route = createFileRoute('/oauth/callback')({ 18 - component: OAuthCallback, 19 - }); 10 + server: { 11 + handlers: { 12 + GET: async ({ request }) => { 13 + const url = new URL(request.url); 20 14 21 - const storeATProtoSession = createServerFn({ method: 'POST' }) 22 - .inputValidator((data: { session: Session }) => data) 23 - .handler(async ({ data }) => { 24 - // TODO: validate session data 15 + if (url.searchParams.has('error')) { 16 + const oauth_error = url.searchParams.get('error_description') ?? ''; 17 + throw Route.redirect({ to: '/', search: { oauth_error } }); 18 + } 25 19 26 - const user = await prisma.user.upsert({ 27 - where: { did: data.session.info.sub }, 28 - update: {}, 29 - create: { 30 - did: data.session.info.sub, 31 - }, 32 - }); 20 + try { 21 + const client = getOAuthClient(); 22 + const { session: oauthSession } = await client.callback( 23 + url.searchParams, 24 + ); 25 + const did = oauthSession.did; 33 26 34 - const token = Buffer.from(crypto.randomBytes(56)).toString('hex'); 27 + await prisma.user.upsert({ 28 + where: { did }, 29 + update: {}, 30 + create: { did }, 31 + }); 35 32 36 - await prisma.session.create({ 37 - data: { 38 - userDid: user.did, 39 - atProtoSessionData: await encrypt(JSON.stringify(data.session)), 40 - token, 41 - }, 42 - }); 33 + const token = Buffer.from(crypto.randomBytes(56)).toString('hex'); 34 + await prisma.session.create({ 35 + data: { userDid: did, token }, 36 + }); 43 37 44 - const session = await useAppSession(); 45 - await session.update({ 46 - token, 47 - }); 48 - }); 49 - 50 - function OAuthCallback() { 51 - const navigate = useNavigate(); 52 - const auth = useAuth(); 38 + const session = await useAppSession(); 39 + await session.update({ token }); 40 + } catch (err) { 41 + const oauth_error = 42 + err instanceof Error ? err.message : 'oauth callback failed'; 43 + throw Route.redirect({ to: '/', search: { oauth_error } }); 44 + } 53 45 54 - onMount(async () => { 55 - const hash = window.location.hash.slice(1); 56 - if (!hash) { 57 - toaster.error({ 58 - title: 'oauth error', 59 - description: 'the url appears to be malformed. no hash has been found', 60 - }); 61 - navigate({ to: '/' }); 62 - return; 63 - } 64 - 65 - const params = new URLSearchParams(hash); 66 - 67 - if (params.has('error')) { 68 - toaster.error({ 69 - title: 'oauth error', 70 - description: 71 - params.get('error_description')?.toLocaleLowerCase() || 72 - 'unknown error', 73 - }); 74 - navigate({ to: '/' }); 75 - return; 76 - } 77 - 78 - try { 79 - const session = await finalizeAuthorization(params); 80 - toaster.success({ 81 - title: 'logged in', 82 - description: `you are now logged in as ${session.session.info.sub}`, 83 - duration: 10000, 84 - }); 85 - 86 - await storeATProtoSession({ data: { session: session.session } }); 87 - auth.refetch(); 88 - } catch (err) { 89 - toaster.error({ 90 - title: 'oauth error', 91 - description: `failed to finalize authorization: ${err}`, 92 - }); 93 - } 94 - 95 - navigate({ to: '/' }); 96 - }); 97 - 98 - return <div>Completing login...</div>; 99 - } 46 + throw Route.redirect({ to: '/' }); 47 + }, 48 + }, 49 + }, 50 + });
+17
app/src/routes/oauth/jwks.ts
··· 1 + import { createFileRoute } from '@tanstack/solid-router'; 2 + 3 + import { getOAuthClient } from '~/server/oauth-client.server'; 4 + 5 + export const Route = createFileRoute('/oauth/jwks')({ 6 + server: { 7 + handlers: { 8 + GET: () => { 9 + const { jwks } = getOAuthClient(); 10 + if (!jwks) return new Response(null, { status: 404 }); 11 + return new Response(JSON.stringify(jwks), { 12 + headers: { 'content-type': 'application/json' }, 13 + }); 14 + }, 15 + }, 16 + }, 17 + });
-81
app/src/server/crypto.server.ts
··· 1 - import { env } from 'cloudflare:workers'; 2 - 3 - const SALT_LENGTH = 16; 4 - const IV_LENGTH = 12; 5 - const PBKDF2_ITERATIONS = 100_000; 6 - 7 - async function deriveKey(secret: string, salt: Uint8Array): Promise<CryptoKey> { 8 - const encoder = new TextEncoder(); 9 - const keyMaterial = await crypto.subtle.importKey( 10 - 'raw', 11 - encoder.encode(secret), 12 - 'PBKDF2', 13 - false, 14 - ['deriveKey'], 15 - ); 16 - 17 - return crypto.subtle.deriveKey( 18 - { 19 - name: 'PBKDF2', 20 - salt: salt.buffer as ArrayBuffer, 21 - iterations: PBKDF2_ITERATIONS, 22 - hash: 'SHA-256', 23 - }, 24 - keyMaterial, 25 - { name: 'AES-GCM', length: 256 }, 26 - false, 27 - ['encrypt', 'decrypt'], 28 - ); 29 - } 30 - 31 - /** 32 - * Encrypts a plaintext string using AES-256-GCM with a key derived from 33 - * ENCRYPTION_KEY via PBKDF2. Returns a base64 string containing 34 - * salt (16B) + iv (12B) + ciphertext (includes GCM auth tag). 35 - */ 36 - export async function encrypt(plaintext: string): Promise<string> { 37 - const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); 38 - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); 39 - const key = await deriveKey(env.ENCRYPTION_KEY, salt); 40 - 41 - const encoder = new TextEncoder(); 42 - const ciphertext = new Uint8Array( 43 - await crypto.subtle.encrypt( 44 - { name: 'AES-GCM', iv }, 45 - key, 46 - encoder.encode(plaintext), 47 - ), 48 - ); 49 - 50 - // Concatenate salt + iv + ciphertext into a single buffer 51 - const result = new Uint8Array( 52 - SALT_LENGTH + IV_LENGTH + ciphertext.byteLength, 53 - ); 54 - result.set(salt, 0); 55 - result.set(iv, SALT_LENGTH); 56 - result.set(ciphertext, SALT_LENGTH + IV_LENGTH); 57 - 58 - return btoa(String.fromCharCode(...result)); 59 - } 60 - 61 - /** 62 - * Decrypts a base64 string produced by `encrypt()` back into the original 63 - * plaintext string. 64 - */ 65 - export async function decrypt(encoded: string): Promise<string> { 66 - const raw = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); 67 - 68 - const salt = raw.slice(0, SALT_LENGTH); 69 - const iv = raw.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); 70 - const ciphertext = raw.slice(SALT_LENGTH + IV_LENGTH); 71 - 72 - const key = await deriveKey(env.ENCRYPTION_KEY, salt); 73 - 74 - const plainBuffer = await crypto.subtle.decrypt( 75 - { name: 'AES-GCM', iv }, 76 - key, 77 - ciphertext, 78 - ); 79 - 80 - return new TextDecoder().decode(plainBuffer); 81 - }
+61
app/src/server/oauth-client.server.ts
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + CompositeHandleResolver, 4 + DohJsonHandleResolver, 5 + LocalActorResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + } from '@atcute/identity-resolver'; 10 + import { OAuthClient } from '@atcute/oauth-node-client'; 11 + import type { ClientAssertionPrivateJwk } from '@atcute/oauth-node-client'; 12 + import { env } from 'cloudflare:workers'; 13 + import * as _ from 'radashi'; 14 + 15 + import { buildOAuthClientMetadata } from '~/lib/oauth-metadata'; 16 + 17 + import { sessionStore, stateStore } from './oauth-stores.server'; 18 + 19 + let client: OAuthClient | undefined; 20 + 21 + export function getOAuthClient(): OAuthClient { 22 + if (client) return client; 23 + 24 + const stores = { sessions: sessionStore, states: stateStore }; 25 + const actorResolver = new LocalActorResolver({ 26 + handleResolver: new CompositeHandleResolver({ 27 + strategy: 'race', 28 + methods: { 29 + dns: new DohJsonHandleResolver({ 30 + dohUrl: 'https://cloudflare-dns.com/dns-query', 31 + }), 32 + http: new WellKnownHandleResolver(), 33 + }, 34 + }), 35 + didDocumentResolver: new CompositeDidDocumentResolver({ 36 + methods: { 37 + plc: new PlcDidDocumentResolver(), 38 + web: new WebDidDocumentResolver(), 39 + }, 40 + }), 41 + }); 42 + 43 + const metadata = buildOAuthClientMetadata(env.BASE_URL); 44 + 45 + if (import.meta.env.DEV) { 46 + client = new OAuthClient({ 47 + metadata: _.pick(metadata, ['scope', 'redirect_uris']), 48 + actorResolver, 49 + stores, 50 + }); 51 + } else { 52 + client = new OAuthClient({ 53 + metadata, 54 + keyset: [JSON.parse(env.PRIVATE_KEY_JWK) as ClientAssertionPrivateJwk], 55 + actorResolver, 56 + stores, 57 + }); 58 + } 59 + 60 + return client; 61 + }
+65
app/src/server/oauth-stores.server.ts
··· 1 + import type { 2 + SessionStore, 3 + StateStore, 4 + StoredSession, 5 + StoredState, 6 + } from '@atcute/oauth-node-client'; 7 + 8 + import { prisma } from './db.server'; 9 + 10 + export const sessionStore: SessionStore = { 11 + async get(did) { 12 + const row = await prisma.oAuthSession.findUnique({ where: { did } }); 13 + if (!row) return undefined; 14 + return { 15 + dpopKey: JSON.parse(row.dpopKey), 16 + authMethod: JSON.parse(row.authMethod), 17 + tokenSet: JSON.parse(row.tokenSet), 18 + } as StoredSession; 19 + }, 20 + async set(did, value) { 21 + const data = { 22 + dpopKey: JSON.stringify(value.dpopKey), 23 + authMethod: JSON.stringify(value.authMethod), 24 + tokenSet: JSON.stringify(value.tokenSet), 25 + }; 26 + await prisma.oAuthSession.upsert({ 27 + where: { did }, 28 + update: data, 29 + create: { did, ...data }, 30 + }); 31 + }, 32 + async delete(did) { 33 + await prisma.oAuthSession.deleteMany({ where: { did } }); 34 + }, 35 + async clear() { 36 + await prisma.oAuthSession.deleteMany({}); 37 + }, 38 + }; 39 + 40 + export const stateStore: StateStore = { 41 + async get(stateId) { 42 + const row = await prisma.oAuthState.findUnique({ where: { stateId } }); 43 + if (!row) return undefined; 44 + if (row.expiresAt.getTime() <= Date.now()) { 45 + await prisma.oAuthState.deleteMany({ where: { stateId } }); 46 + return undefined; 47 + } 48 + return JSON.parse(row.data) as StoredState; 49 + }, 50 + async set(stateId, value) { 51 + const data = JSON.stringify(value); 52 + const expiresAt = new Date(value.expiresAt); 53 + await prisma.oAuthState.upsert({ 54 + where: { stateId }, 55 + update: { data, expiresAt }, 56 + create: { stateId, data, expiresAt }, 57 + }); 58 + }, 59 + async delete(stateId) { 60 + await prisma.oAuthState.deleteMany({ where: { stateId } }); 61 + }, 62 + async clear() { 63 + await prisma.oAuthState.deleteMany({}); 64 + }, 65 + };
+20
app/src/server/oauth.ts
··· 1 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 2 + import type { ActorIdentifier } from '@atcute/lexicons/syntax'; 3 + import { createServerFn } from '@tanstack/solid-start'; 4 + 5 + import { getOAuthClient } from './oauth-client.server'; 6 + 7 + export const startAuthorizationFn = createServerFn({ method: 'POST' }) 8 + .inputValidator((data: { identifier: string }) => { 9 + if (!isHandle(data.identifier) && !isDid(data.identifier)) { 10 + throw new Error('invalid handle or DID'); 11 + } 12 + return data as { identifier: ActorIdentifier }; 13 + }) 14 + .handler(async ({ data }) => { 15 + const client = getOAuthClient(); 16 + const { url } = await client.authorize({ 17 + target: { type: 'account', identifier: data.identifier }, 18 + }); 19 + return { url: url.toString() }; 20 + });
-10
app/src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 /// <reference types="@atcute/bluesky" /> 3 - 4 - interface ImportMetaEnv { 5 - readonly VITE_OAUTH_REDIRECT_URL: string; 6 - readonly VITE_OAUTH_CLIENT_URI: string; 7 - readonly VITE_OAUTH_SCOPE: string; 8 - } 9 - 10 - interface ImportMeta { 11 - readonly env: ImportMetaEnv; 12 - }
-11
app/vite.config.ts
··· 6 6 import lucidePreprocess from 'vite-plugin-lucide-preprocess'; 7 7 import solidPlugin from 'vite-plugin-solid'; 8 8 9 - import metadata from './public/oauth-client-metadata.json'; 10 - 11 9 export default defineConfig({ 12 10 plugins: [ 13 11 lucidePreprocess(), ··· 24 22 // for OAuth, "localhost" hostname is not allowed (RFC 8252) 25 23 host: '127.0.0.1', 26 24 forwardConsole: true, 27 - }, 28 - define: { 29 - 'import.meta.env.VITE_OAUTH_REDIRECT_URL': JSON.stringify( 30 - metadata.redirect_uris[0], 31 - ), 32 - 'import.meta.env.VITE_OAUTH_CLIENT_URI': JSON.stringify( 33 - metadata.client_uri, 34 - ), 35 - 'import.meta.env.VITE_OAUTH_SCOPE': JSON.stringify(metadata.scope), 36 25 }, 37 26 });
+28 -34
pnpm-lock.yaml
··· 25 25 '@atcute/lexicons': 26 26 specifier: ^1.3.0 27 27 version: 1.3.0 28 - '@atcute/oauth-browser-client': 29 - specifier: ^3.0.0 30 - version: 3.0.0(@atcute/identity@1.1.3) 31 - '@atproto/jwk': 32 - specifier: ^0.6.0 33 - version: 0.6.0 28 + '@atcute/oauth-node-client': 29 + specifier: ^1.1.0 30 + version: 1.1.0 34 31 '@cloudflare/vite-plugin': 35 32 specifier: ^1.35.0 36 33 version: 1.35.0(vite@8.0.10(@types/node@24.10.12)(esbuild@0.27.3)(jiti@2.6.1))(workerd@1.20260430.1)(wrangler@4.87.0(@cloudflare/workers-types@4.20260501.1)) ··· 118 115 prisma: 119 116 specifier: 7.8.0 120 117 version: 7.8.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.3) 118 + radashi: 119 + specifier: ^12.9.0 120 + version: 12.9.0 121 121 solid-js: 122 122 specifier: ^1.9.12 123 123 version: 1.9.12 ··· 179 179 '@atcute/multibase@1.1.8': 180 180 resolution: {integrity: sha512-pJgtImMZKCjqwRbu+2GzB+4xQjKBXDwdZOzeqe0u97zYKRGftpGYGvYv3+pMe2xXe+msDyu7Nv8iJp+U14otTA==} 181 181 182 - '@atcute/oauth-browser-client@3.0.0': 183 - resolution: {integrity: sha512-7AbKV8tTe7aRJNJV7gCcWHSVEADb2nr58O1p7dQsf73HSe9pvlBkj/Vk1yjjtH691uAVYkwhHSh0bC7D8XdwJw==} 184 - 185 182 '@atcute/oauth-crypto@0.1.0': 186 183 resolution: {integrity: sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w==} 187 184 188 185 '@atcute/oauth-keyset@0.1.0': 189 186 resolution: {integrity: sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ==} 187 + 188 + '@atcute/oauth-node-client@1.1.0': 189 + resolution: {integrity: sha512-xCp/VfjtvTeKscKR/oI2hdMTp1/DaF/7ll8b6yZOCgbKlVDDfhCn5mmKNVARGTNaoywxrXG3XffbWCIx3/E87w==} 190 190 191 191 '@atcute/oauth-types@0.1.1': 192 192 resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==} ··· 199 199 200 200 '@atcute/util-text@1.3.1': 201 201 resolution: {integrity: sha512-MRgJXkx67znuBXuoAYCJkBZyd3OApL7zZlNf5kXhuoCXcdiu1nblRDycYTADSkym4epBSQWxh26kmI9sewaq6A==} 202 - 203 - '@atproto/jwk@0.6.0': 204 - resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 205 202 206 203 '@babel/code-frame@7.27.1': 207 204 resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} ··· 2750 2747 ms@2.1.3: 2751 2748 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 2752 2749 2753 - multiformats@9.9.0: 2754 - resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 2755 - 2756 2750 mysql2@3.15.3: 2757 2751 resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} 2758 2752 engines: {node: '>= 8.0'} ··· 2963 2957 2964 2958 pure-rand@6.1.0: 2965 2959 resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} 2960 + 2961 + radashi@12.9.0: 2962 + resolution: {integrity: sha512-a0VUpLVwlZKLCE+JP5bBG4kN8oEfe2+wn9zF6HcUXvHGRNBYQXdqlHksYAVIxranbLcSWuXqMpAbgTnF/dyG4A==} 2963 + engines: {node: '>=16.0.0'} 2966 2964 2967 2965 rc9@3.0.1: 2968 2966 resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} ··· 3498 3496 dependencies: 3499 3497 '@atcute/uint8array': 1.1.1 3500 3498 3501 - '@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3)': 3502 - dependencies: 3503 - '@atcute/client': 4.2.1 3504 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 3505 - '@atcute/lexicons': 1.3.0 3506 - '@atcute/multibase': 1.1.8 3507 - '@atcute/oauth-crypto': 0.1.0 3508 - '@atcute/oauth-types': 0.1.1 3509 - nanoid: 5.1.6 3510 - transitivePeerDependencies: 3511 - - '@atcute/identity' 3512 - 3513 3499 '@atcute/oauth-crypto@0.1.0': 3514 3500 dependencies: 3515 3501 '@atcute/multibase': 1.1.8 ··· 3521 3507 dependencies: 3522 3508 '@atcute/oauth-crypto': 0.1.0 3523 3509 3510 + '@atcute/oauth-node-client@1.1.0': 3511 + dependencies: 3512 + '@atcute/client': 4.2.1 3513 + '@atcute/identity': 1.1.3 3514 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 3515 + '@atcute/lexicons': 1.3.0 3516 + '@atcute/oauth-crypto': 0.1.0 3517 + '@atcute/oauth-keyset': 0.1.0 3518 + '@atcute/oauth-types': 0.1.1 3519 + '@atcute/util-fetch': 1.0.5 3520 + '@badrap/valita': 0.4.6 3521 + nanoid: 5.1.6 3522 + 3524 3523 '@atcute/oauth-types@0.1.1': 3525 3524 dependencies: 3526 3525 '@atcute/identity': 1.1.3 ··· 3537 3536 '@atcute/util-text@1.3.1': 3538 3537 dependencies: 3539 3538 unicode-segmenter: 0.14.5 3540 - 3541 - '@atproto/jwk@0.6.0': 3542 - dependencies: 3543 - multiformats: 9.9.0 3544 - zod: 3.25.76 3545 3539 3546 3540 '@babel/code-frame@7.27.1': 3547 3541 dependencies: ··· 6364 6358 6365 6359 ms@2.1.3: {} 6366 6360 6367 - multiformats@9.9.0: {} 6368 - 6369 6361 mysql2@3.15.3: 6370 6362 dependencies: 6371 6363 aws-ssl-profiles: 1.1.2 ··· 6520 6512 punycode@2.3.1: {} 6521 6513 6522 6514 pure-rand@6.1.0: {} 6515 + 6516 + radashi@12.9.0: {} 6523 6517 6524 6518 rc9@3.0.1: 6525 6519 dependencies: