A decentralized music tracking and discovery platform built on AT Protocol 🎵

Add CustomOAuthClient with PAR and DPoP support

+109 -5
+109 -2
apps/api/src/auth/client.ts
··· 1 1 import { JoseKey } from "@atproto/jwk-jose"; 2 - import { NodeOAuthClient, type RuntimeLock } from "@atproto/oauth-client-node"; 2 + import { 3 + AuthorizeOptions, 4 + NodeOAuthClient, 5 + NodeOAuthClientOptions, 6 + OAuthAuthorizationRequestParameters, 7 + type RuntimeLock, 8 + } from "@atproto/oauth-client-node"; 3 9 import Redis from "ioredis"; 4 10 import Redlock from "redlock"; 5 11 import type { Database } from "../db"; 6 12 import { env } from "../lib/env"; 7 13 import { SessionStore, StateStore } from "./storage"; 8 14 15 + export const FALLBACK_ALG = "ES256"; 16 + 17 + export class CustomOAuthClient extends NodeOAuthClient { 18 + constructor(options: NodeOAuthClientOptions) { 19 + super(options); 20 + } 21 + 22 + async authorize( 23 + input: string, 24 + { signal, ...options }: AuthorizeOptions = {}, 25 + ): Promise<URL> { 26 + const redirectUri = 27 + options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]; 28 + if (!this.clientMetadata.redirect_uris.includes(redirectUri)) { 29 + // The server will enforce this, but let's catch it early 30 + throw new TypeError("Invalid redirect_uri"); 31 + } 32 + 33 + const { identity, metadata } = await this.oauthResolver.resolve(input, { 34 + signal, 35 + }); 36 + 37 + const pkce = await this.runtime.generatePKCE(); 38 + const dpopKey = await this.runtime.generateKey( 39 + metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG], 40 + ); 41 + 42 + const state = await this.runtime.generateNonce(); 43 + 44 + await this.stateStore.set(state, { 45 + iss: metadata.issuer, 46 + dpopKey, 47 + verifier: pkce.verifier, 48 + appState: options?.state, 49 + }); 50 + 51 + const parameters: OAuthAuthorizationRequestParameters = { 52 + ...options, 53 + 54 + client_id: this.clientMetadata.client_id, 55 + redirect_uri: redirectUri, 56 + code_challenge: pkce.challenge, 57 + code_challenge_method: pkce.method, 58 + state, 59 + login_hint: identity && !options.prompt ? input : undefined, 60 + response_mode: this.responseMode, 61 + response_type: "code" as const, 62 + scope: options?.scope ?? this.clientMetadata.scope, 63 + }; 64 + 65 + const authorizationUrl = new URL(metadata.authorization_endpoint); 66 + 67 + // Since the user will be redirected to the authorization_endpoint url using 68 + // a browser, we need to make sure that the url is valid. 69 + if ( 70 + authorizationUrl.protocol !== "https:" && 71 + authorizationUrl.protocol !== "http:" 72 + ) { 73 + throw new TypeError( 74 + `Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`, 75 + ); 76 + } 77 + 78 + if (metadata.pushed_authorization_request_endpoint) { 79 + const server = await this.serverFactory.fromMetadata(metadata, dpopKey); 80 + const parResponse = await server.request( 81 + "pushed_authorization_request", 82 + parameters, 83 + ); 84 + 85 + authorizationUrl.searchParams.set( 86 + "client_id", 87 + this.clientMetadata.client_id, 88 + ); 89 + authorizationUrl.searchParams.set("request_uri", parResponse.request_uri); 90 + return authorizationUrl; 91 + } else if (metadata.require_pushed_authorization_requests) { 92 + throw new Error( 93 + "Server requires pushed authorization requests (PAR) but no PAR endpoint is available", 94 + ); 95 + } else { 96 + for (const [key, value] of Object.entries(parameters)) { 97 + if (value) authorizationUrl.searchParams.set(key, String(value)); 98 + } 99 + 100 + // Length of the URL that will be sent to the server 101 + const urlLength = 102 + authorizationUrl.pathname.length + authorizationUrl.search.length; 103 + if (urlLength < 2048) { 104 + return authorizationUrl; 105 + } else if (!metadata.pushed_authorization_request_endpoint) { 106 + throw new Error("Login URL too long"); 107 + } 108 + } 109 + 110 + throw new Error( 111 + "Server does not support pushed authorization requests (PAR)", 112 + ); 113 + } 114 + } 115 + 9 116 export const createClient = async (db: Database) => { 10 117 const publicUrl = env.PUBLIC_URL; 11 118 const url = publicUrl.includes("localhost") ··· 25 132 } 26 133 }; 27 134 28 - return new NodeOAuthClient({ 135 + return new CustomOAuthClient({ 29 136 clientMetadata: { 30 137 client_name: "Rocksky", 31 138 client_id: !publicUrl.includes("localhost")
-3
apps/api/src/bsky/app.ts
··· 39 39 if (cli) { 40 40 ctx.kv.set(`cli:${handle}`, "1"); 41 41 } 42 - if (prompt) { 43 - url.searchParams.delete("login_hint"); 44 - } 45 42 return c.redirect(url.toString()); 46 43 } catch (e) { 47 44 c.status(500);