Skip to content

feature(auth): Allow delegating OAuth authorization to existing app-level implementations #485

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
311 changes: 232 additions & 79 deletions src/client/auth.test.ts
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

افتح فئة DebugTree : Timber.Tree

Original file line number Diff line number Diff line change
Expand Up @@ -713,99 +713,252 @@ describe("OAuth Authorization", () => {
});

describe("auth function", () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() {
return {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
};
},
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
};
describe("well-known discovery", () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() {
return {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
};
},
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.clearAllMocks();
});

it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
// Setup: First call to protected resource metadata fails (404)
// Second call to auth server metadata succeeds
let callCount = 0;
mockFetch.mockImplementation((url) => {
callCount++;

const urlString = url.toString();

if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
// First call - protected resource metadata fails with 404
return Promise.resolve({
ok: false,
status: 404,
});
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
// Second call - auth server metadata succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (callCount === 3 && urlString.includes("/register")) {
// Third call - client registration succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: "test-client-id",
client_secret: "test-client-secret",
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
}),
});
}

return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
});

// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = jest.fn();

// Call the auth function
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
});

// Verify the result
expect(result).toBe("REDIRECT");

// Verify the sequence of calls
expect(mockFetch).toHaveBeenCalledTimes(3);

// First call should be to protected resource metadata
expect(mockFetch.mock.calls[0][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-protected-resource"
);

// Second call should be to oauth metadata
expect(mockFetch.mock.calls[1][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-authorization-server"
);
});
});

it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
// Setup: First call to protected resource metadata fails (404)
// Second call to auth server metadata succeeds
let callCount = 0;
mockFetch.mockImplementation((url) => {
callCount++;
describe("delegateAuthorization", () => {
const validMetadata = {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
};

const urlString = url.toString();
const validClientInfo = {
client_id: "client123",
client_secret: "secret123",
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
};

if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
// First call - protected resource metadata fails with 404
return Promise.resolve({
ok: false,
status: 404,
});
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
// Second call - auth server metadata succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (callCount === 3 && urlString.includes("/register")) {
// Third call - client registration succeeds
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: "test-client-id",
client_secret: "test-client-secret",
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client",
}),
});
}
const validTokens = {
access_token: "access123",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "refresh123",
};

return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
// Setup shared mock function for all tests
beforeEach(() => {
// Reset mockFetch implementation
mockFetch.mockReset();

// Set up the mockFetch to respond to all necessary API calls
mockFetch.mockImplementation((url) => {
const urlString = url.toString();

if (urlString.includes("/.well-known/oauth-protected-resource")) {
return Promise.resolve({
ok: false,
status: 404
});
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
} else if (urlString.includes("/token")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validTokens
});
}

return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
});
});

// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = jest.fn();
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
const mockProvider: OAuthClientProvider = {
redirectUrl: "http://localhost:3000/callback",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client"
},
clientInformation: () => validClientInfo,
tokens: () => validTokens,
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: () => "test_verifier",
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
};

const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });

// Call the auth function
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
expect(result).toBe("AUTHORIZED");
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
"https://auth.example.com",
expect.objectContaining(validMetadata)
);
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
});

// Verify the result
expect(result).toBe("REDIRECT");
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
const mockProvider: OAuthClientProvider = {
redirectUrl: "http://localhost:3000/callback",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client"
},
clientInformation: () => validClientInfo,
tokens: () => validTokens,
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: () => "test_verifier",
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
};

// Verify the sequence of calls
expect(mockFetch).toHaveBeenCalledTimes(3);
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });

// First call should be to protected resource metadata
expect(mockFetch.mock.calls[0][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-protected-resource"
);
expect(result).toBe("AUTHORIZED");
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
expect(mockProvider.saveTokens).toHaveBeenCalled();
});

// Second call should be to oauth metadata
expect(mockFetch.mock.calls[1][0].toString()).toBe(
"https://resource.example.com/.well-known/oauth-authorization-server"
);
it("should not call delegateAuthorization when processing authorizationCode", async () => {
const mockProvider: OAuthClientProvider = {
redirectUrl: "http://localhost:3000/callback",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client"
},
clientInformation: () => validClientInfo,
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: () => "test_verifier",
delegateAuthorization: jest.fn()
};

await auth(mockProvider, {
serverUrl: "https://auth.example.com",
authorizationCode: "code123"
});

expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
expect(mockProvider.saveTokens).toHaveBeenCalled();
});

it("should propagate errors from delegateAuthorization", async () => {
const mockProvider: OAuthClientProvider = {
redirectUrl: "http://localhost:3000/callback",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "Test Client"
},
clientInformation: () => validClientInfo,
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: () => "test_verifier",
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
};

await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
.rejects.toThrow("Delegation failed");
});
});
});
});
32 changes: 32 additions & 0 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ export interface OAuthClientProvider {
* the authorization result.
*/
codeVerifier(): string | Promise<string>;

/**
* Optional method that allows the OAuth client to delegate authorization
* to an existing implementation, such as a platform or app-level identity provider.
*
* If this method returns "AUTHORIZED", the standard authorization flow will be bypassed.
* If it returns `undefined`, the SDK will proceed with its default OAuth implementation.
*
* When returning "AUTHORIZED", the implementation must ensure tokens have been saved
* through the provider's saveTokens method, or are accessible via the tokens() method.
*
* This method is useful when the host application already manages OAuth tokens or user sessions
* and does not need the SDK to handle the entire authorization flow directly.
*
* For example, in a mobile app, this could delegate to the native platform authentication,
* or in a browser application, it could use existing tokens from localStorage.
*
* Note: This method will NOT be called when processing an authorization code callback.
*
* @param serverUrl The URL of the authorization server.
* @param metadata The OAuth metadata if available.
* @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`.
*/
delegateAuthorization?(serverUrl: string | URL, metadata: OAuthMetadata | undefined): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>;
}

export type AuthResult = "AUTHORIZED" | "REDIRECT";
Expand Down Expand Up @@ -113,6 +137,14 @@ export async function auth(

const metadata = await discoverOAuthMetadata(authorizationServerUrl);

// Delegate the authorization if supported and if not already in the middle of the standard flow
if (provider.delegateAuthorization && authorizationCode === undefined) {
const result = await provider.delegateAuthorization(authorizationServerUrl, metadata);
if (result === "AUTHORIZED") {
return "AUTHORIZED";
}
}

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
Expand Down