diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eba7074b..547ec974 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -202,6 +202,49 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.has("scope")).toBe(false); }); + it("includes resource parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + resources: ["https://api.example.com/resource"], + } + ); + + expect(authorizationUrl.searchParams.get("resource")).toBe( + "https://api.example.com/resource" + ); + }); + + it("includes multiple resource parameters when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + resources: ["https://api.example.com/resource1", "https://api.example.com/resource2"], + } + ); + + expect(authorizationUrl.searchParams.getAll("resource")).toEqual([ + "https://api.example.com/resource1", + "https://api.example.com/resource2", + ]); + }); + + it("excludes resource parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("resource")).toBe(false); + }); + it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", diff --git a/src/client/auth.ts b/src/client/auth.ts index e4941576..849ce351 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -66,6 +66,19 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * The resource to be used for the current session. + * + * Implements RFC 8707 Resource Indicators. + * + * This is placed in the provider to ensure the strong binding between tokens + * and their intended resource throughout the authorization session. + * + * This method is optional and only needs to be implemented if using + * Resource Indicators (RFC 8707). + */ + resource?(): string | undefined; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -123,6 +136,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, + resource: provider.resource?.(), }); await provider.saveTokens(tokens); @@ -139,6 +153,7 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, + resource: provider.resource?.(), }); await provider.saveTokens(newTokens); @@ -149,12 +164,22 @@ export async function auth( } // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { - metadata, - clientInformation, - redirectUrl: provider.redirectUrl, - scope: scope || provider.clientMetadata.scope, - }); + const resource = provider.resource?.(); + const { authorizationUrl, codeVerifier } = await startAuthorization( + serverUrl, + { + metadata, + clientInformation, + redirectUrl: provider.redirectUrl, + scope: scope || provider.clientMetadata.scope, + /** + * Although RFC 8707 supports multiple resources, we currently only support + * a single resource per auth session to maintain a 1:1 token-resource binding + * based on current auth flow implementation + */ + resources: resource ? [resource] : undefined, + } + ); await provider.saveCodeVerifier(codeVerifier); await provider.redirectToAuthorization(authorizationUrl); @@ -211,12 +236,19 @@ export async function startAuthorization( clientInformation, redirectUrl, scope, + resources, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; - }, + /** + * Array type to align with RFC 8707 which supports multiple resources, + * making it easier to extend for multiple resource indicators in the future + * (though current implementation only uses a single resource) + */ + resources?: string[]; + } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; const codeChallengeMethod = "S256"; @@ -261,6 +293,12 @@ export async function startAuthorization( authorizationUrl.searchParams.set("scope", scope); } + if (resources?.length) { + for (const resource of resources) { + authorizationUrl.searchParams.append("resource", resource); + } + } + return { authorizationUrl, codeVerifier }; } @@ -275,13 +313,15 @@ export async function exchangeAuthorization( authorizationCode, codeVerifier, redirectUri, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - }, + resource?: string; + } ): Promise { const grantType = "authorization_code"; @@ -308,6 +348,7 @@ export async function exchangeAuthorization( code: authorizationCode, code_verifier: codeVerifier, redirect_uri: String(redirectUri), + ...(resource ? { resource } : {}), }); if (clientInformation.client_secret) { @@ -338,11 +379,13 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; - }, + resource?: string; + } ): Promise { const grantType = "refresh_token"; @@ -367,6 +410,7 @@ export async function refreshAuthorization( grant_type: grantType, client_id: clientInformation.client_id, refresh_token: refreshToken, + ...(resource ? { resource } : {}), }); if (clientInformation.client_secret) {