Skip to content

Commit 644d97d

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 824c1f5 commit 644d97d

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
@@ -714,99 +714,252 @@ describe("OAuth Authorization", () => {
714714
});
715715

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

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

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++;
814+
describe("delegateAuthorization", () => {
815+
const validMetadata = {
816+
issuer: "https://auth.example.com",
817+
authorization_endpoint: "https://auth.example.com/authorize",
818+
token_endpoint: "https://auth.example.com/token",
819+
registration_endpoint: "https://auth.example.com/register",
820+
response_types_supported: ["code"],
821+
code_challenge_methods_supported: ["S256"],
822+
};
743823

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

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-
}
831+
const validTokens = {
832+
access_token: "access123",
833+
token_type: "Bearer",
834+
expires_in: 3600,
835+
refresh_token: "refresh123",
836+
};
781837

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

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();
870+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
871+
const mockProvider: OAuthClientProvider = {
872+
redirectUrl: "http://localhost:3000/callback",
873+
clientMetadata: {
874+
redirect_uris: ["http://localhost:3000/callback"],
875+
client_name: "Test Client"
876+
},
877+
clientInformation: () => validClientInfo,
878+
tokens: () => validTokens,
879+
saveTokens: jest.fn(),
880+
redirectToAuthorization: jest.fn(),
881+
saveCodeVerifier: jest.fn(),
882+
codeVerifier: () => "test_verifier",
883+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
884+
};
885+
886+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
789887

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

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

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

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-
);
914+
expect(result).toBe("AUTHORIZED");
915+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
916+
expect(mockProvider.saveTokens).toHaveBeenCalled();
917+
});
805918

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-
);
919+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
920+
const mockProvider: OAuthClientProvider = {
921+
redirectUrl: "http://localhost:3000/callback",
922+
clientMetadata: {
923+
redirect_uris: ["http://localhost:3000/callback"],
924+
client_name: "Test Client"
925+
},
926+
clientInformation: () => validClientInfo,
927+
tokens: jest.fn(),
928+
saveTokens: jest.fn(),
929+
redirectToAuthorization: jest.fn(),
930+
saveCodeVerifier: jest.fn(),
931+
codeVerifier: () => "test_verifier",
932+
delegateAuthorization: jest.fn()
933+
};
934+
935+
await auth(mockProvider, {
936+
serverUrl: "https://auth.example.com",
937+
authorizationCode: "code123"
938+
});
939+
940+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
941+
expect(mockProvider.saveTokens).toHaveBeenCalled();
942+
});
943+
944+
it("should propagate errors from delegateAuthorization", async () => {
945+
const mockProvider: OAuthClientProvider = {
946+
redirectUrl: "http://localhost:3000/callback",
947+
clientMetadata: {
948+
redirect_uris: ["http://localhost:3000/callback"],
949+
client_name: "Test Client"
950+
},
951+
clientInformation: () => validClientInfo,
952+
tokens: jest.fn(),
953+
saveTokens: jest.fn(),
954+
redirectToAuthorization: jest.fn(),
955+
saveCodeVerifier: jest.fn(),
956+
codeVerifier: () => "test_verifier",
957+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
958+
};
959+
960+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
961+
.rejects.toThrow("Delegation failed");
962+
});
810963
});
811964
});
812965
});

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)