···11import { JoseKey } from "@atproto/jwk-jose";
22-import {
33- AuthorizeOptions,
44- NodeOAuthClient,
55- NodeOAuthClientOptions,
66- OAuthAuthorizationRequestParameters,
77- type RuntimeLock,
88-} from "@atproto/oauth-client-node";
22+import type { RuntimeLock } from "@atproto/oauth-client-node";
93import Redis from "ioredis";
104import Redlock from "redlock";
115import type { Database } from "../db";
126import { env } from "../lib/env";
137import { SessionStore, StateStore } from "./storage";
1414-1515-export const FALLBACK_ALG = "ES256";
1616-1717-export class CustomOAuthClient extends NodeOAuthClient {
1818- constructor(options: NodeOAuthClientOptions) {
1919- super(options);
2020- }
2121-2222- async authorize(
2323- input: string,
2424- { signal, ...options }: AuthorizeOptions = {},
2525- ): Promise<URL> {
2626- const redirectUri =
2727- options?.redirect_uri ?? this.clientMetadata.redirect_uris[0];
2828- if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
2929- // The server will enforce this, but let's catch it early
3030- throw new TypeError("Invalid redirect_uri");
3131- }
3232-3333- const { identity, metadata } = await this.oauthResolver.resolve(input, {
3434- signal,
3535- });
3636-3737- const pkce = await this.runtime.generatePKCE();
3838- const dpopKey = await this.runtime.generateKey(
3939- metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
4040- );
4141-4242- const state = await this.runtime.generateNonce();
4343-4444- await this.stateStore.set(state, {
4545- iss: metadata.issuer,
4646- dpopKey,
4747- verifier: pkce.verifier,
4848- appState: options?.state,
4949- });
88+import { CustomOAuthClient } from "./oauth-client";
5095151- const parameters: OAuthAuthorizationRequestParameters = {
5252- ...options,
5353-5454- client_id: this.clientMetadata.client_id,
5555- redirect_uri: redirectUri,
5656- code_challenge: pkce.challenge,
5757- code_challenge_method: pkce.method,
5858- state,
5959- login_hint: identity && !options.prompt ? input : undefined,
6060- response_mode: this.responseMode,
6161- response_type: "code" as const,
6262- scope: options?.scope ?? this.clientMetadata.scope,
6363- };
6464-6565- const authorizationUrl = new URL(metadata.authorization_endpoint);
6666-6767- // Since the user will be redirected to the authorization_endpoint url using
6868- // a browser, we need to make sure that the url is valid.
6969- if (
7070- authorizationUrl.protocol !== "https:" &&
7171- authorizationUrl.protocol !== "http:"
7272- ) {
7373- throw new TypeError(
7474- `Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`,
7575- );
7676- }
7777-7878- if (metadata.pushed_authorization_request_endpoint) {
7979- const server = await this.serverFactory.fromMetadata(metadata, dpopKey);
8080- const parResponse = await server.request(
8181- "pushed_authorization_request",
8282- parameters,
8383- );
8484-8585- authorizationUrl.searchParams.set(
8686- "client_id",
8787- this.clientMetadata.client_id,
8888- );
8989- authorizationUrl.searchParams.set("request_uri", parResponse.request_uri);
9090- return authorizationUrl;
9191- } else if (metadata.require_pushed_authorization_requests) {
9292- throw new Error(
9393- "Server requires pushed authorization requests (PAR) but no PAR endpoint is available",
9494- );
9595- } else {
9696- for (const [key, value] of Object.entries(parameters)) {
9797- if (value) authorizationUrl.searchParams.set(key, String(value));
9898- }
9999-100100- // Length of the URL that will be sent to the server
101101- const urlLength =
102102- authorizationUrl.pathname.length + authorizationUrl.search.length;
103103- if (urlLength < 2048) {
104104- return authorizationUrl;
105105- } else if (!metadata.pushed_authorization_request_endpoint) {
106106- throw new Error("Login URL too long");
107107- }
108108- }
109109-110110- throw new Error(
111111- "Server does not support pushed authorization requests (PAR)",
112112- );
113113- }
114114-}
1010+export const SCOPES = [
1111+ "atproto",
1212+ "repo:app.rocksky.album",
1313+ "repo:app.rocksky.artist",
1414+ "repo:app.rocksky.graph.follow",
1515+ "repo:app.rocksky.like",
1616+ "repo:app.rocksky.playlist",
1717+ "repo:app.rocksky.scrobble",
1818+ "repo:app.rocksky.shout",
1919+ "repo:app.rocksky.song",
2020+ "repo:app.rocksky.feed.generator",
2121+ "repo:fm.teal.alpha.feed.play",
2222+ "repo:fm.teal.alpha.actor.status",
2323+];
1152411625export const createClient = async (db: Database) => {
11726 const publicUrl = env.PUBLIC_URL;
···13948 ? `${url}/oauth-client-metadata.json`
14049 : `http://localhost?redirect_uri=${enc(
14150 `${url}/oauth/callback`,
142142- )}&scope=${enc("atproto transition:generic")}`,
5151+ )}&scope=${enc(SCOPES.join(" "))}`,
14352 client_uri: url,
14453 redirect_uris: [`${url}/oauth/callback`],
145145- scope: "atproto transition:generic",
5454+ scope: SCOPES.join(" "),
14655 grant_types: ["authorization_code", "refresh_token"],
14756 response_types: ["code"],
14857 application_type: "web",
+84
apps/api/src/auth/oauth-client-auth.ts
···11+import type {
22+ ClientMetadata,
33+ Keyset,
44+ OAuthAuthorizationServerMetadata,
55+} from "@atproto/oauth-client-node";
66+77+import type { ClientAuthMethod } from "@atproto/oauth-client/dist/oauth-client-auth";
88+99+export const FALLBACK_ALG = "ES256";
1010+1111+function supportedMethods(serverMetadata: OAuthAuthorizationServerMetadata) {
1212+ return serverMetadata["token_endpoint_auth_methods_supported"];
1313+}
1414+1515+function supportedAlgs(serverMetadata: OAuthAuthorizationServerMetadata) {
1616+ return (
1717+ serverMetadata["token_endpoint_auth_signing_alg_values_supported"] ?? [
1818+ // @NOTE If not specified, assume that the server supports the ES256
1919+ // algorithm, as prescribed by the spec:
2020+ //
2121+ // > Clients and Authorization Servers currently must support the ES256
2222+ // > cryptographic system [for client authentication].
2323+ //
2424+ // https://atproto.com/specs/oauth#confidential-client-authentication
2525+ FALLBACK_ALG,
2626+ ]
2727+ );
2828+}
2929+3030+export function negotiateClientAuthMethod(
3131+ serverMetadata: OAuthAuthorizationServerMetadata,
3232+ clientMetadata: ClientMetadata,
3333+ keyset?: Keyset,
3434+): ClientAuthMethod {
3535+ const method = clientMetadata.token_endpoint_auth_method;
3636+3737+ // @NOTE ATproto spec requires that AS support both "none" and
3838+ // "private_key_jwt", and that clients use one of the other. The following
3939+ // check ensures that the AS is indeed compliant with this client's
4040+ // configuration.
4141+ const methods = supportedMethods(serverMetadata);
4242+ if (!methods.includes(method)) {
4343+ throw new Error(
4444+ `The server does not support "${method}" authentication. Supported methods are: ${methods.join(
4545+ ", ",
4646+ )}.`,
4747+ );
4848+ }
4949+5050+ if (method === "private_key_jwt") {
5151+ // Invalid client configuration. This should not happen as
5252+ // "validateClientMetadata" already check this.
5353+ if (!keyset) throw new Error("A keyset is required for private_key_jwt");
5454+5555+ const alg = supportedAlgs(serverMetadata);
5656+5757+ // @NOTE we can't use `keyset.findPrivateKey` here because we can't enforce
5858+ // that the returned key contains a "kid". The following implementation is
5959+ // more robust against keysets containing keys without a "kid" property.
6060+ for (const key of keyset.list({ alg, usage: "sign" })) {
6161+ // Return the first key from the key set that matches the server's
6262+ // supported algorithms.
6363+ if (key.kid) return { method: "private_key_jwt", kid: key.kid };
6464+ }
6565+6666+ throw new Error(
6767+ alg.includes(FALLBACK_ALG)
6868+ ? `Client authentication method "${method}" requires at least one "${FALLBACK_ALG}" signing key with a "kid" property`
6969+ : // AS is not compliant with the ATproto OAuth spec.
7070+ `Authorization server requires "${method}" authentication method, but does not support "${FALLBACK_ALG}" algorithm.`,
7171+ );
7272+ }
7373+7474+ if (method === "none") {
7575+ return { method: "none" };
7676+ }
7777+7878+ throw new Error(
7979+ `The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.` +
8080+ (method === "client_secret_basic"
8181+ ? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
8282+ : ` You set "${method}" which is not allowed.`),
8383+ );
8484+}
+116
apps/api/src/auth/oauth-client.ts
···11+import {
22+ type AuthorizeOptions,
33+ NodeOAuthClient,
44+ type NodeOAuthClientOptions,
55+ type OAuthAuthorizationRequestParameters,
66+} from "@atproto/oauth-client-node";
77+import { FALLBACK_ALG, negotiateClientAuthMethod } from "./oauth-client-auth";
88+99+export class CustomOAuthClient extends NodeOAuthClient {
1010+ constructor(options: NodeOAuthClientOptions) {
1111+ super(options);
1212+ }
1313+1414+ async authorize(
1515+ input: string,
1616+ { signal, ...options }: AuthorizeOptions = {},
1717+ ): Promise<URL> {
1818+ const redirectUri =
1919+ options?.redirect_uri ?? this.clientMetadata.redirect_uris[0];
2020+ if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
2121+ // The server will enforce this, but let's catch it early
2222+ throw new TypeError("Invalid redirect_uri");
2323+ }
2424+2525+ const { identityInfo, metadata } = await this.oauthResolver.resolve(input, {
2626+ signal,
2727+ });
2828+2929+ const pkce = await this.runtime.generatePKCE();
3030+ const dpopKey = await this.runtime.generateKey(
3131+ metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
3232+ );
3333+3434+ const authMethod = negotiateClientAuthMethod(
3535+ metadata,
3636+ this.clientMetadata,
3737+ this.keyset,
3838+ );
3939+ const state = await this.runtime.generateNonce();
4040+4141+ await this.stateStore.set(state, {
4242+ iss: metadata.issuer,
4343+ authMethod,
4444+ dpopKey,
4545+ verifier: pkce.verifier,
4646+ appState: options?.state,
4747+ });
4848+4949+ const parameters: OAuthAuthorizationRequestParameters = {
5050+ ...options,
5151+5252+ client_id: this.clientMetadata.client_id,
5353+ redirect_uri: redirectUri,
5454+ code_challenge: pkce.challenge,
5555+ code_challenge_method: pkce.method,
5656+ state,
5757+ login_hint: identityInfo && !options.prompt ? input : undefined,
5858+ response_mode: this.responseMode,
5959+ response_type: "code" as const,
6060+ scope: options?.scope ?? this.clientMetadata.scope,
6161+ };
6262+6363+ const authorizationUrl = new URL(metadata.authorization_endpoint);
6464+6565+ // Since the user will be redirected to the authorization_endpoint url using
6666+ // a browser, we need to make sure that the url is valid.
6767+ if (
6868+ authorizationUrl.protocol !== "https:" &&
6969+ authorizationUrl.protocol !== "http:"
7070+ ) {
7171+ throw new TypeError(
7272+ `Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`,
7373+ );
7474+ }
7575+7676+ if (metadata.pushed_authorization_request_endpoint) {
7777+ const server = await this.serverFactory.fromMetadata(
7878+ metadata,
7979+ authMethod,
8080+ dpopKey,
8181+ );
8282+ const parResponse = await server.request(
8383+ "pushed_authorization_request",
8484+ parameters,
8585+ );
8686+8787+ authorizationUrl.searchParams.set(
8888+ "client_id",
8989+ this.clientMetadata.client_id,
9090+ );
9191+ authorizationUrl.searchParams.set("request_uri", parResponse.request_uri);
9292+ return authorizationUrl;
9393+ } else if (metadata.require_pushed_authorization_requests) {
9494+ throw new Error(
9595+ "Server requires pushed authorization requests (PAR) but no PAR endpoint is available",
9696+ );
9797+ } else {
9898+ for (const [key, value] of Object.entries(parameters)) {
9999+ if (value) authorizationUrl.searchParams.set(key, String(value));
100100+ }
101101+102102+ // Length of the URL that will be sent to the server
103103+ const urlLength =
104104+ authorizationUrl.pathname.length + authorizationUrl.search.length;
105105+ if (urlLength < 2048) {
106106+ return authorizationUrl;
107107+ } else if (!metadata.pushed_authorization_request_endpoint) {
108108+ throw new Error("Login URL too long");
109109+ }
110110+ }
111111+112112+ throw new Error(
113113+ "Server does not support pushed authorization requests (PAR)",
114114+ );
115115+ }
116116+}