Skip to content

Commit 200a23e

Browse files
committed
feature(auth): OAuthClientProvider.delegateAuthorization
An optional method that clients can use whenever the authorization should be delegated to an existing implementation.
1 parent 0c4b308 commit 200a23e

File tree

2 files changed

+264
-79
lines changed

2 files changed

+264
-79
lines changed

src/client/auth.test.ts

Lines changed: 232 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -713,99 +713,252 @@ describe("OAuth Authorization", () => {
713713
});
714714

715715
describe("auth function", () => {
716-
const mockProvider: OAuthClientProvider = {
717-
get redirectUrl() { return "http://localhost:3000/callback"; },
718-
get clientMetadata() {
719-
return {
720-
redirect_uris: ["http://localhost:3000/callback"],
721-
client_name: "Test Client",
722-
};
723-
},
724-
clientInformation: jest.fn(),
725-
tokens: jest.fn(),
726-
saveTokens: jest.fn(),
727-
redirectToAuthorization: jest.fn(),
728-
saveCodeVerifier: jest.fn(),
729-
codeVerifier: jest.fn(),
730-
};
716+
describe("well-known discovery", () => {
717+
const mockProvider: OAuthClientProvider = {
718+
get redirectUrl() { return "http://localhost:3000/callback"; },
719+
get clientMetadata() {
720+
return {
721+
redirect_uris: ["http://localhost:3000/callback"],
722+
client_name: "Test Client",
723+
};
724+
},
725+
clientInformation: jest.fn(),
726+
tokens: jest.fn(),
727+
saveTokens: jest.fn(),
728+
redirectToAuthorization: jest.fn(),
729+
saveCodeVerifier: jest.fn(),
730+
codeVerifier: jest.fn(),
731+
};
731732

732-
beforeEach(() => {
733-
jest.clearAllMocks();
733+
beforeEach(() => {
734+
jest.clearAllMocks();
735+
});
736+
737+
it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
738+
// Setup: First call to protected resource metadata fails (404)
739+
// Second call to auth server metadata succeeds
740+
let callCount = 0;
741+
mockFetch.mockImplementation((url) => {
742+
callCount++;
743+
744+
const urlString = url.toString();
745+
746+
if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
747+
// First call - protected resource metadata fails with 404
748+
return Promise.resolve({
749+
ok: false,
750+
status: 404,
751+
});
752+
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
753+
// Second call - auth server metadata succeeds
754+
return Promise.resolve({
755+
ok: true,
756+
status: 200,
757+
json: async () => ({
758+
issuer: "https://auth.example.com",
759+
authorization_endpoint: "https://auth.example.com/authorize",
760+
token_endpoint: "https://auth.example.com/token",
761+
registration_endpoint: "https://auth.example.com/register",
762+
response_types_supported: ["code"],
763+
code_challenge_methods_supported: ["S256"],
764+
}),
765+
});
766+
} else if (callCount === 3 && urlString.includes("/register")) {
767+
// Third call - client registration succeeds
768+
return Promise.resolve({
769+
ok: true,
770+
status: 200,
771+
json: async () => ({
772+
client_id: "test-client-id",
773+
client_secret: "test-client-secret",
774+
client_id_issued_at: 1612137600,
775+
client_secret_expires_at: 1612224000,
776+
redirect_uris: ["http://localhost:3000/callback"],
777+
client_name: "Test Client",
778+
}),
779+
});
780+
}
781+
782+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
783+
});
784+
785+
// Mock provider methods
786+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
787+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
788+
mockProvider.saveClientInformation = jest.fn();
789+
790+
// Call the auth function
791+
const result = await auth(mockProvider, {
792+
serverUrl: "https://resource.example.com",
793+
});
794+
795+
// Verify the result
796+
expect(result).toBe("REDIRECT");
797+
798+
// Verify the sequence of calls
799+
expect(mockFetch).toHaveBeenCalledTimes(3);
800+
801+
// First call should be to protected resource metadata
802+
expect(mockFetch.mock.calls[0][0].toString()).toBe(
803+
"https://resource.example.com/.well-known/oauth-protected-resource"
804+
);
805+
806+
// Second call should be to oauth metadata
807+
expect(mockFetch.mock.calls[1][0].toString()).toBe(
808+
"https://resource.example.com/.well-known/oauth-authorization-server"
809+
);
810+
});
734811
});
735812

736-
it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
737-
// Setup: First call to protected resource metadata fails (404)
738-
// Second call to auth server metadata succeeds
739-
let callCount = 0;
740-
mockFetch.mockImplementation((url) => {
741-
callCount++;
813+
describe("delegateAuthorization", () => {
814+
const validMetadata = {
815+
issuer: "https://auth.example.com",
816+
authorization_endpoint: "https://auth.example.com/authorize",
817+
token_endpoint: "https://auth.example.com/token",
818+
registration_endpoint: "https://auth.example.com/register",
819+
response_types_supported: ["code"],
820+
code_challenge_methods_supported: ["S256"],
821+
};
742822

743-
const urlString = url.toString();
823+
const validClientInfo = {
824+
client_id: "client123",
825+
client_secret: "secret123",
826+
redirect_uris: ["http://localhost:3000/callback"],
827+
client_name: "Test Client",
828+
};
744829

745-
if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
746-
// First call - protected resource metadata fails with 404
747-
return Promise.resolve({
748-
ok: false,
749-
status: 404,
750-
});
751-
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
752-
// Second call - auth server metadata succeeds
753-
return Promise.resolve({
754-
ok: true,
755-
status: 200,
756-
json: async () => ({
757-
issuer: "https://auth.example.com",
758-
authorization_endpoint: "https://auth.example.com/authorize",
759-
token_endpoint: "https://auth.example.com/token",
760-
registration_endpoint: "https://auth.example.com/register",
761-
response_types_supported: ["code"],
762-
code_challenge_methods_supported: ["S256"],
763-
}),
764-
});
765-
} else if (callCount === 3 && urlString.includes("/register")) {
766-
// Third call - client registration succeeds
767-
return Promise.resolve({
768-
ok: true,
769-
status: 200,
770-
json: async () => ({
771-
client_id: "test-client-id",
772-
client_secret: "test-client-secret",
773-
client_id_issued_at: 1612137600,
774-
client_secret_expires_at: 1612224000,
775-
redirect_uris: ["http://localhost:3000/callback"],
776-
client_name: "Test Client",
777-
}),
778-
});
779-
}
830+
const validTokens = {
831+
access_token: "access123",
832+
token_type: "Bearer",
833+
expires_in: 3600,
834+
refresh_token: "refresh123",
835+
};
780836

781-
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
837+
// Setup shared mock function for all tests
838+
beforeEach(() => {
839+
// Reset mockFetch implementation
840+
mockFetch.mockReset();
841+
842+
// Set up the mockFetch to respond to all necessary API calls
843+
mockFetch.mockImplementation((url) => {
844+
const urlString = url.toString();
845+
846+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
847+
return Promise.resolve({
848+
ok: false,
849+
status: 404
850+
});
851+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
852+
return Promise.resolve({
853+
ok: true,
854+
status: 200,
855+
json: async () => validMetadata
856+
});
857+
} else if (urlString.includes("/token")) {
858+
return Promise.resolve({
859+
ok: true,
860+
status: 200,
861+
json: async () => validTokens
862+
});
863+
}
864+
865+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
866+
});
782867
});
783868

784-
// Mock provider methods
785-
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
786-
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
787-
mockProvider.saveClientInformation = jest.fn();
869+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
870+
const mockProvider: OAuthClientProvider = {
871+
redirectUrl: "http://localhost:3000/callback",
872+
clientMetadata: {
873+
redirect_uris: ["http://localhost:3000/callback"],
874+
client_name: "Test Client"
875+
},
876+
clientInformation: () => validClientInfo,
877+
tokens: () => validTokens,
878+
saveTokens: jest.fn(),
879+
redirectToAuthorization: jest.fn(),
880+
saveCodeVerifier: jest.fn(),
881+
codeVerifier: () => "test_verifier",
882+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
883+
};
884+
885+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
788886

789-
// Call the auth function
790-
const result = await auth(mockProvider, {
791-
serverUrl: "https://resource.example.com",
887+
expect(result).toBe("AUTHORIZED");
888+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
889+
"https://auth.example.com",
890+
expect.objectContaining(validMetadata)
891+
);
892+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
792893
});
793894

794-
// Verify the result
795-
expect(result).toBe("REDIRECT");
895+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
896+
const mockProvider: OAuthClientProvider = {
897+
redirectUrl: "http://localhost:3000/callback",
898+
clientMetadata: {
899+
redirect_uris: ["http://localhost:3000/callback"],
900+
client_name: "Test Client"
901+
},
902+
clientInformation: () => validClientInfo,
903+
tokens: () => validTokens,
904+
saveTokens: jest.fn(),
905+
redirectToAuthorization: jest.fn(),
906+
saveCodeVerifier: jest.fn(),
907+
codeVerifier: () => "test_verifier",
908+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
909+
};
796910

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

800-
// First call should be to protected resource metadata
801-
expect(mockFetch.mock.calls[0][0].toString()).toBe(
802-
"https://resource.example.com/.well-known/oauth-protected-resource"
803-
);
913+
expect(result).toBe("AUTHORIZED");
914+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
915+
expect(mockProvider.saveTokens).toHaveBeenCalled();
916+
});
804917

805-
// Second call should be to oauth metadata
806-
expect(mockFetch.mock.calls[1][0].toString()).toBe(
807-
"https://resource.example.com/.well-known/oauth-authorization-server"
808-
);
918+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
919+
const mockProvider: OAuthClientProvider = {
920+
redirectUrl: "http://localhost:3000/callback",
921+
clientMetadata: {
922+
redirect_uris: ["http://localhost:3000/callback"],
923+
client_name: "Test Client"
924+
},
925+
clientInformation: () => validClientInfo,
926+
tokens: jest.fn(),
927+
saveTokens: jest.fn(),
928+
redirectToAuthorization: jest.fn(),
929+
saveCodeVerifier: jest.fn(),
930+
codeVerifier: () => "test_verifier",
931+
delegateAuthorization: jest.fn()
932+
};
933+
934+
await auth(mockProvider, {
935+
serverUrl: "https://auth.example.com",
936+
authorizationCode: "code123"
937+
});
938+
939+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
940+
expect(mockProvider.saveTokens).toHaveBeenCalled();
941+
});
942+
943+
it("should propagate errors from delegateAuthorization", async () => {
944+
const mockProvider: OAuthClientProvider = {
945+
redirectUrl: "http://localhost:3000/callback",
946+
clientMetadata: {
947+
redirect_uris: ["http://localhost:3000/callback"],
948+
client_name: "Test Client"
949+
},
950+
clientInformation: () => validClientInfo,
951+
tokens: jest.fn(),
952+
saveTokens: jest.fn(),
953+
redirectToAuthorization: jest.fn(),
954+
saveCodeVerifier: jest.fn(),
955+
codeVerifier: () => "test_verifier",
956+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
957+
};
958+
959+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
960+
.rejects.toThrow("Delegation failed");
961+
});
809962
});
810963
});
811964
});

src/client/auth.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,30 @@ export interface OAuthClientProvider {
7171
* the authorization result.
7272
*/
7373
codeVerifier(): string | Promise<string>;
74+
75+
/**
76+
* Optional method that allows the OAuth client to delegate authorization
77+
* to an existing implementation, such as a platform or app-level identity provider.
78+
*
79+
* If this method returns "AUTHORIZED", the standard authorization flow will be bypassed.
80+
* If it returns `undefined`, the SDK will proceed with its default OAuth implementation.
81+
*
82+
* When returning "AUTHORIZED", the implementation must ensure tokens have been saved
83+
* through the provider's saveTokens method, or are accessible via the tokens() method.
84+
*
85+
* This method is useful when the host application already manages OAuth tokens or user sessions
86+
* and does not need the SDK to handle the entire authorization flow directly.
87+
*
88+
* For example, in a mobile app, this could delegate to the native platform authentication,
89+
* or in a browser application, it could use existing tokens from localStorage.
90+
*
91+
* Note: This method will NOT be called when processing an authorization code callback.
92+
*
93+
* @param serverUrl The URL of the authorization server.
94+
* @param metadata The OAuth metadata if available.
95+
* @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`.
96+
*/
97+
delegateAuthorization?(serverUrl: string | URL, metadata: OAuthMetadata | undefined): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>;
7498
}
7599

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

114138
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
115139

140+
// Delegate the authorization if supported and if not already in the middle of the standard flow
141+
if (provider.delegateAuthorization && authorizationCode === undefined) {
142+
const result = await provider.delegateAuthorization(authorizationServerUrl, metadata);
143+
if (result === "AUTHORIZED") {
144+
return "AUTHORIZED";
145+
}
146+
}
147+
116148
// Handle client registration if needed
117149
let clientInformation = await Promise.resolve(provider.clientInformation());
118150
if (!clientInformation) {

0 commit comments

Comments
 (0)