Skip to content

feat(auth): support resource indicators in auth flow #498

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 53 additions & 9 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ export interface OAuthClientProvider {
* the authorization result.
*/
codeVerifier(): string | Promise<string>;

/**
* 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";
Expand Down Expand Up @@ -123,6 +136,7 @@ export async function auth(
authorizationCode,
codeVerifier,
redirectUri: provider.redirectUrl,
resource: provider.resource?.(),
});

await provider.saveTokens(tokens);
Expand All @@ -139,6 +153,7 @@ export async function auth(
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
resource: provider.resource?.(),
});

await provider.saveTokens(newTokens);
Expand All @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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<OAuthTokens> {
const grantType = "authorization_code";

Expand All @@ -308,6 +348,7 @@ export async function exchangeAuthorization(
code: authorizationCode,
code_verifier: codeVerifier,
redirect_uri: String(redirectUri),
...(resource ? { resource } : {}),
});

if (clientInformation.client_secret) {
Expand Down Expand Up @@ -338,11 +379,13 @@ export async function refreshAuthorization(
metadata,
clientInformation,
refreshToken,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
refreshToken: string;
},
resource?: string;
}
): Promise<OAuthTokens> {
const grantType = "refresh_token";

Expand All @@ -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) {
Expand Down