@better-auth/sso 1.4.7-beta.3 → 1.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-m7FISidt.d.mts → index-B9WMxRdD.d.mts} +325 -19
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +454 -16
- package/package.json +3 -3
- package/src/index.ts +27 -0
- package/src/oidc/discovery.test.ts +1157 -0
- package/src/oidc/discovery.ts +494 -0
- package/src/oidc/errors.ts +92 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +219 -0
- package/src/oidc.test.ts +3 -164
- package/src/routes/sso.ts +192 -23
- package/src/saml.test.ts +302 -57
- package/src/types.ts +32 -6
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery Types
|
|
3
|
+
*
|
|
4
|
+
* Types for the OIDC discovery document and hydrated configuration.
|
|
5
|
+
* Based on OpenID Connect Discovery 1.0 specification.
|
|
6
|
+
*
|
|
7
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Raw OIDC Discovery Document as returned by the IdP's
|
|
12
|
+
* .well-known/openid-configuration endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Required fields for Better Auth's OIDC support:
|
|
15
|
+
* - issuer
|
|
16
|
+
* - authorization_endpoint
|
|
17
|
+
* - token_endpoint
|
|
18
|
+
* - jwks_uri (required for ID token validation)
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
export interface OIDCDiscoveryDocument {
|
|
22
|
+
/** REQUIRED. URL using the https scheme that the OP asserts as its Issuer Identifier. */
|
|
23
|
+
issuer: string;
|
|
24
|
+
|
|
25
|
+
/** REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint. */
|
|
26
|
+
authorization_endpoint: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* REQUIRED (spec says "unless only implicit flow is used").
|
|
30
|
+
* URL of the OP's OAuth 2.0 Token Endpoint.
|
|
31
|
+
* We only support authorization code flow.
|
|
32
|
+
*/
|
|
33
|
+
token_endpoint: string;
|
|
34
|
+
|
|
35
|
+
/** REQUIRED. URL of the OP's JSON Web Key Set document for ID token validation. */
|
|
36
|
+
jwks_uri: string;
|
|
37
|
+
|
|
38
|
+
/** RECOMMENDED. URL of the OP's UserInfo Endpoint. */
|
|
39
|
+
userinfo_endpoint?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OPTIONAL. JSON array containing a list of Client Authentication methods
|
|
43
|
+
* supported by this Token Endpoint.
|
|
44
|
+
* Default: ["client_secret_basic"]
|
|
45
|
+
*/
|
|
46
|
+
token_endpoint_auth_methods_supported?: string[];
|
|
47
|
+
|
|
48
|
+
/** OPTIONAL. JSON array containing a list of the OAuth 2.0 scope values that this server supports. */
|
|
49
|
+
scopes_supported?: string[];
|
|
50
|
+
|
|
51
|
+
/** OPTIONAL. JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. */
|
|
52
|
+
response_types_supported?: string[];
|
|
53
|
+
|
|
54
|
+
/** OPTIONAL. JSON array containing a list of the Subject Identifier types that this OP supports. */
|
|
55
|
+
subject_types_supported?: string[];
|
|
56
|
+
|
|
57
|
+
/** OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the OP. */
|
|
58
|
+
id_token_signing_alg_values_supported?: string[];
|
|
59
|
+
|
|
60
|
+
/** OPTIONAL. JSON array containing a list of the claim names that the OP may supply values for. */
|
|
61
|
+
claims_supported?: string[];
|
|
62
|
+
|
|
63
|
+
/** OPTIONAL. URL of a page containing human-readable information about the OP. */
|
|
64
|
+
service_documentation?: string;
|
|
65
|
+
|
|
66
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the claims parameter. */
|
|
67
|
+
claims_parameter_supported?: boolean;
|
|
68
|
+
|
|
69
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the request parameter. */
|
|
70
|
+
request_parameter_supported?: boolean;
|
|
71
|
+
|
|
72
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the request_uri parameter. */
|
|
73
|
+
request_uri_parameter_supported?: boolean;
|
|
74
|
+
|
|
75
|
+
/** OPTIONAL. Boolean value specifying whether the OP requires any request_uri values to be pre-registered. */
|
|
76
|
+
require_request_uri_registration?: boolean;
|
|
77
|
+
|
|
78
|
+
/** OPTIONAL. URL of the OP's end session endpoint. */
|
|
79
|
+
end_session_endpoint?: string;
|
|
80
|
+
|
|
81
|
+
/** OPTIONAL. URL of the OP's revocation endpoint. */
|
|
82
|
+
revocation_endpoint?: string;
|
|
83
|
+
|
|
84
|
+
/** OPTIONAL. URL of the OP's introspection endpoint. */
|
|
85
|
+
introspection_endpoint?: string;
|
|
86
|
+
|
|
87
|
+
/** OPTIONAL. JSON array of PKCE code challenge methods supported (e.g., "S256", "plain"). */
|
|
88
|
+
code_challenge_methods_supported?: string[];
|
|
89
|
+
|
|
90
|
+
/** Allow additional fields from the discovery document */
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Error codes for OIDC discovery operations.
|
|
96
|
+
*/
|
|
97
|
+
export type DiscoveryErrorCode =
|
|
98
|
+
/** Request to discovery endpoint timed out */
|
|
99
|
+
| "discovery_timeout"
|
|
100
|
+
/** Discovery endpoint returned 404 or similar */
|
|
101
|
+
| "discovery_not_found"
|
|
102
|
+
/** Discovery endpoint returned invalid JSON */
|
|
103
|
+
| "discovery_invalid_json"
|
|
104
|
+
/** Discovery URL is invalid or malformed */
|
|
105
|
+
| "discovery_invalid_url"
|
|
106
|
+
/** Discovery URL is not trusted by the trusted origins configuration */
|
|
107
|
+
| "discovery_untrusted_origin"
|
|
108
|
+
/** Discovery document issuer doesn't match configured issuer */
|
|
109
|
+
| "issuer_mismatch"
|
|
110
|
+
/** Discovery document is missing required fields */
|
|
111
|
+
| "discovery_incomplete"
|
|
112
|
+
/** IdP only advertises token auth methods that Better Auth doesn't currently support */
|
|
113
|
+
| "unsupported_token_auth_method"
|
|
114
|
+
/** Catch-all for unexpected errors */
|
|
115
|
+
| "discovery_unexpected_error";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Custom error class for OIDC discovery failures.
|
|
119
|
+
* Can be caught and mapped to APIError at the edge.
|
|
120
|
+
*/
|
|
121
|
+
export class DiscoveryError extends Error {
|
|
122
|
+
public readonly code: DiscoveryErrorCode;
|
|
123
|
+
public readonly details?: Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
code: DiscoveryErrorCode,
|
|
127
|
+
message: string,
|
|
128
|
+
details?: Record<string, unknown>,
|
|
129
|
+
options?: { cause?: unknown },
|
|
130
|
+
) {
|
|
131
|
+
super(message, options);
|
|
132
|
+
this.name = "DiscoveryError";
|
|
133
|
+
this.code = code;
|
|
134
|
+
this.details = details;
|
|
135
|
+
|
|
136
|
+
// Maintains proper stack trace for where the error was thrown
|
|
137
|
+
if (Error.captureStackTrace) {
|
|
138
|
+
Error.captureStackTrace(this, DiscoveryError);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Hydrated OIDC configuration after discovery.
|
|
145
|
+
* This is the normalized shape that gets persisted to the database
|
|
146
|
+
* or merged into provider config at runtime.
|
|
147
|
+
*
|
|
148
|
+
* Field names are camelCase to match Better Auth conventions.
|
|
149
|
+
*/
|
|
150
|
+
export interface HydratedOIDCConfig {
|
|
151
|
+
/** The issuer URL (validated to match configured issuer) */
|
|
152
|
+
issuer: string;
|
|
153
|
+
|
|
154
|
+
/** The discovery endpoint URL */
|
|
155
|
+
discoveryEndpoint: string;
|
|
156
|
+
|
|
157
|
+
/** URL of the authorization endpoint */
|
|
158
|
+
authorizationEndpoint: string;
|
|
159
|
+
|
|
160
|
+
/** URL of the token endpoint */
|
|
161
|
+
tokenEndpoint: string;
|
|
162
|
+
|
|
163
|
+
/** URL of the JWKS endpoint */
|
|
164
|
+
jwksEndpoint: string;
|
|
165
|
+
|
|
166
|
+
/** URL of the userinfo endpoint (optional) */
|
|
167
|
+
userInfoEndpoint?: string;
|
|
168
|
+
|
|
169
|
+
/** Token endpoint authentication method */
|
|
170
|
+
tokenEndpointAuthentication?: "client_secret_basic" | "client_secret_post";
|
|
171
|
+
|
|
172
|
+
/** Scopes supported by the IdP */
|
|
173
|
+
scopesSupported?: string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parameters for the discoverOIDCConfig function.
|
|
178
|
+
*/
|
|
179
|
+
export interface DiscoverOIDCConfigParams {
|
|
180
|
+
/** The issuer URL to discover configuration from */
|
|
181
|
+
issuer: string;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Optional existing configuration.
|
|
185
|
+
* Values provided here will override discovered values.
|
|
186
|
+
*/
|
|
187
|
+
existingConfig?: Partial<HydratedOIDCConfig>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Optional custom discovery endpoint URL.
|
|
191
|
+
* If not provided, defaults to <issuer>/.well-known/openid-configuration
|
|
192
|
+
*/
|
|
193
|
+
discoveryEndpoint?: string;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Optional timeout in milliseconds for the discovery request.
|
|
197
|
+
* @default 10000 (10 seconds)
|
|
198
|
+
*/
|
|
199
|
+
timeout?: number;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Trusted origin predicate. See "trustedOrigins" option
|
|
203
|
+
* @param url the url to test
|
|
204
|
+
* @returns {boolean} return true for urls that belong to a trusted origin and false otherwise
|
|
205
|
+
*/
|
|
206
|
+
isTrustedOrigin: (url: string) => boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Required fields that must be present in a valid discovery document.
|
|
211
|
+
*/
|
|
212
|
+
export const REQUIRED_DISCOVERY_FIELDS = [
|
|
213
|
+
"issuer",
|
|
214
|
+
"authorization_endpoint",
|
|
215
|
+
"token_endpoint",
|
|
216
|
+
"jwks_uri",
|
|
217
|
+
] as const;
|
|
218
|
+
|
|
219
|
+
export type RequiredDiscoveryField = (typeof REQUIRED_DISCOVERY_FIELDS)[number];
|
package/src/oidc.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ let server = new OAuth2Server();
|
|
|
12
12
|
describe("SSO", async () => {
|
|
13
13
|
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
|
|
14
14
|
await getTestInstance({
|
|
15
|
+
trustedOrigins: ["http://localhost:8080"],
|
|
15
16
|
plugins: [sso(), organization()],
|
|
16
17
|
});
|
|
17
18
|
|
|
@@ -257,6 +258,7 @@ describe("SSO", async () => {
|
|
|
257
258
|
describe("SSO disable implicit sign in", async () => {
|
|
258
259
|
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
|
|
259
260
|
await getTestInstance({
|
|
261
|
+
trustedOrigins: ["http://localhost:8080"],
|
|
260
262
|
plugins: [sso({ disableImplicitSignUp: true }), organization()],
|
|
261
263
|
});
|
|
262
264
|
|
|
@@ -419,6 +421,7 @@ describe("SSO disable implicit sign in", async () => {
|
|
|
419
421
|
describe("provisioning", async (ctx) => {
|
|
420
422
|
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
|
|
421
423
|
await getTestInstance({
|
|
424
|
+
trustedOrigins: ["http://localhost:8080"],
|
|
422
425
|
plugins: [sso(), organization()],
|
|
423
426
|
});
|
|
424
427
|
|
|
@@ -571,167 +574,3 @@ describe("provisioning", async (ctx) => {
|
|
|
571
574
|
expect(res.url).toContain("http://localhost:8080/authorize");
|
|
572
575
|
});
|
|
573
576
|
});
|
|
574
|
-
|
|
575
|
-
describe("OIDC account linking with domainVerified", async () => {
|
|
576
|
-
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
|
|
577
|
-
await getTestInstance({
|
|
578
|
-
account: {
|
|
579
|
-
accountLinking: {
|
|
580
|
-
enabled: true,
|
|
581
|
-
trustedProviders: [],
|
|
582
|
-
},
|
|
583
|
-
},
|
|
584
|
-
plugins: [
|
|
585
|
-
sso({
|
|
586
|
-
domainVerification: {
|
|
587
|
-
enabled: true,
|
|
588
|
-
},
|
|
589
|
-
}),
|
|
590
|
-
],
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
const authClient = createAuthClient({
|
|
594
|
-
plugins: [ssoClient()],
|
|
595
|
-
baseURL: "http://localhost:3000",
|
|
596
|
-
fetchOptions: {
|
|
597
|
-
customFetchImpl,
|
|
598
|
-
},
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
beforeAll(async () => {
|
|
602
|
-
await server.issuer.keys.generate("RS256");
|
|
603
|
-
await server.start(8080, "localhost");
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
afterAll(async () => {
|
|
607
|
-
await server.stop().catch(() => {});
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
async function simulateOAuthFlow(authUrl: string, headers: Headers) {
|
|
611
|
-
let location: string | null = null;
|
|
612
|
-
await betterFetch(authUrl, {
|
|
613
|
-
method: "GET",
|
|
614
|
-
redirect: "manual",
|
|
615
|
-
onError(context) {
|
|
616
|
-
location = context.response.headers.get("location");
|
|
617
|
-
},
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
if (!location) throw new Error("No redirect location found");
|
|
621
|
-
|
|
622
|
-
let callbackURL = "";
|
|
623
|
-
const newHeaders = new Headers();
|
|
624
|
-
await betterFetch(location, {
|
|
625
|
-
method: "GET",
|
|
626
|
-
customFetchImpl,
|
|
627
|
-
headers,
|
|
628
|
-
onError(context) {
|
|
629
|
-
callbackURL = context.response.headers.get("location") || "";
|
|
630
|
-
cookieSetter(newHeaders)(context);
|
|
631
|
-
},
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
return { callbackURL, headers: newHeaders };
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
it("should allow account linking when domain is verified and email domain matches", async () => {
|
|
638
|
-
const testEmail = "linking-test@verified-oidc.com";
|
|
639
|
-
const testDomain = "verified-oidc.com";
|
|
640
|
-
|
|
641
|
-
server.service.on("beforeTokenSigning", (token) => {
|
|
642
|
-
token.payload.email = testEmail;
|
|
643
|
-
token.payload.email_verified = false;
|
|
644
|
-
token.payload.name = "Domain Verified User";
|
|
645
|
-
token.payload.sub = "oidc-domain-verified-user";
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const { headers } = await signInWithTestUser();
|
|
649
|
-
|
|
650
|
-
const provider = await auth.api.registerSSOProvider({
|
|
651
|
-
body: {
|
|
652
|
-
providerId: "domain-verified-oidc",
|
|
653
|
-
issuer: server.issuer.url!,
|
|
654
|
-
domain: testDomain,
|
|
655
|
-
oidcConfig: {
|
|
656
|
-
clientId: "test",
|
|
657
|
-
clientSecret: "test",
|
|
658
|
-
authorizationEndpoint: `${server.issuer.url}/authorize`,
|
|
659
|
-
tokenEndpoint: `${server.issuer.url}/token`,
|
|
660
|
-
jwksEndpoint: `${server.issuer.url}/jwks`,
|
|
661
|
-
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
|
|
662
|
-
mapping: {
|
|
663
|
-
id: "sub",
|
|
664
|
-
email: "email",
|
|
665
|
-
emailVerified: "email_verified",
|
|
666
|
-
name: "name",
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
},
|
|
670
|
-
headers,
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
expect(provider.domainVerified).toBe(false);
|
|
674
|
-
|
|
675
|
-
const ctx = await auth.$context;
|
|
676
|
-
await ctx.adapter.update({
|
|
677
|
-
model: "ssoProvider",
|
|
678
|
-
where: [{ field: "providerId", value: provider.providerId }],
|
|
679
|
-
update: {
|
|
680
|
-
domainVerified: true,
|
|
681
|
-
},
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
const updatedProvider = await ctx.adapter.findOne<{
|
|
685
|
-
domainVerified: boolean;
|
|
686
|
-
domain: string;
|
|
687
|
-
}>({
|
|
688
|
-
model: "ssoProvider",
|
|
689
|
-
where: [{ field: "providerId", value: provider.providerId }],
|
|
690
|
-
});
|
|
691
|
-
expect(updatedProvider?.domainVerified).toBe(true);
|
|
692
|
-
|
|
693
|
-
await ctx.adapter.create({
|
|
694
|
-
model: "user",
|
|
695
|
-
data: {
|
|
696
|
-
id: "existing-oidc-domain-user",
|
|
697
|
-
email: testEmail,
|
|
698
|
-
name: "Existing User",
|
|
699
|
-
emailVerified: true,
|
|
700
|
-
createdAt: new Date(),
|
|
701
|
-
updatedAt: new Date(),
|
|
702
|
-
},
|
|
703
|
-
forceAllowId: true,
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
const newHeaders = new Headers();
|
|
707
|
-
const res = await authClient.signIn.sso({
|
|
708
|
-
providerId: "domain-verified-oidc",
|
|
709
|
-
callbackURL: "/dashboard",
|
|
710
|
-
fetchOptions: {
|
|
711
|
-
throw: true,
|
|
712
|
-
onSuccess: cookieSetter(newHeaders),
|
|
713
|
-
},
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
expect(res.url).toContain("http://localhost:8080/authorize");
|
|
717
|
-
|
|
718
|
-
const { callbackURL } = await simulateOAuthFlow(res.url, newHeaders);
|
|
719
|
-
|
|
720
|
-
expect(callbackURL).toContain("/dashboard");
|
|
721
|
-
expect(callbackURL).not.toContain("error");
|
|
722
|
-
|
|
723
|
-
const accounts = await ctx.adapter.findMany<{
|
|
724
|
-
providerId: string;
|
|
725
|
-
accountId: string;
|
|
726
|
-
userId: string;
|
|
727
|
-
}>({
|
|
728
|
-
model: "account",
|
|
729
|
-
where: [{ field: "userId", value: "existing-oidc-domain-user" }],
|
|
730
|
-
});
|
|
731
|
-
const linkedAccount = accounts.find(
|
|
732
|
-
(a) => a.providerId === "domain-verified-oidc",
|
|
733
|
-
);
|
|
734
|
-
expect(linkedAccount).toBeTruthy();
|
|
735
|
-
expect(linkedAccount?.accountId).toBe("oidc-domain-verified-user");
|
|
736
|
-
});
|
|
737
|
-
});
|
package/src/routes/sso.ts
CHANGED
|
@@ -24,11 +24,98 @@ import type { FlowResult } from "samlify/types/src/flow";
|
|
|
24
24
|
import * as z from "zod/v4";
|
|
25
25
|
import type { AuthnRequestRecord } from "../authn-request-store";
|
|
26
26
|
import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
|
|
27
|
+
import type { HydratedOIDCConfig } from "../oidc";
|
|
28
|
+
import {
|
|
29
|
+
DiscoveryError,
|
|
30
|
+
discoverOIDCConfig,
|
|
31
|
+
mapDiscoveryErrorToAPIError,
|
|
32
|
+
} from "../oidc";
|
|
27
33
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
28
34
|
|
|
29
35
|
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
30
36
|
|
|
31
37
|
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
38
|
+
|
|
39
|
+
/** Default clock skew tolerance: 5 minutes */
|
|
40
|
+
export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
export interface TimestampValidationOptions {
|
|
43
|
+
clockSkew?: number;
|
|
44
|
+
requireTimestamps?: boolean;
|
|
45
|
+
logger?: {
|
|
46
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Conditions extracted from SAML assertion */
|
|
51
|
+
export interface SAMLConditions {
|
|
52
|
+
notBefore?: string;
|
|
53
|
+
notOnOrAfter?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
58
|
+
* Prevents acceptance of expired or future-dated assertions.
|
|
59
|
+
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
60
|
+
*/
|
|
61
|
+
export function validateSAMLTimestamp(
|
|
62
|
+
conditions: SAMLConditions | undefined,
|
|
63
|
+
options: TimestampValidationOptions = {},
|
|
64
|
+
): void {
|
|
65
|
+
const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
66
|
+
const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
|
|
67
|
+
|
|
68
|
+
if (!hasTimestamps) {
|
|
69
|
+
if (options.requireTimestamps) {
|
|
70
|
+
throw new APIError("BAD_REQUEST", {
|
|
71
|
+
message: "SAML assertion missing required timestamp conditions",
|
|
72
|
+
details:
|
|
73
|
+
"Assertions must include NotBefore and/or NotOnOrAfter conditions",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Log warning for missing timestamps when not required
|
|
77
|
+
options.logger?.warn(
|
|
78
|
+
"SAML assertion accepted without timestamp conditions",
|
|
79
|
+
{ hasConditions: !!conditions },
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
|
|
86
|
+
if (conditions?.notBefore) {
|
|
87
|
+
const notBeforeTime = new Date(conditions.notBefore).getTime();
|
|
88
|
+
if (Number.isNaN(notBeforeTime)) {
|
|
89
|
+
throw new APIError("BAD_REQUEST", {
|
|
90
|
+
message: "SAML assertion has invalid NotBefore timestamp",
|
|
91
|
+
details: `Unable to parse NotBefore value: ${conditions.notBefore}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (now < notBeforeTime - clockSkew) {
|
|
95
|
+
throw new APIError("BAD_REQUEST", {
|
|
96
|
+
message: "SAML assertion is not yet valid",
|
|
97
|
+
details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (conditions?.notOnOrAfter) {
|
|
103
|
+
const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
|
|
104
|
+
if (Number.isNaN(notOnOrAfterTime)) {
|
|
105
|
+
throw new APIError("BAD_REQUEST", {
|
|
106
|
+
message: "SAML assertion has invalid NotOnOrAfter timestamp",
|
|
107
|
+
details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (now > notOnOrAfterTime + clockSkew) {
|
|
111
|
+
throw new APIError("BAD_REQUEST", {
|
|
112
|
+
message: "SAML assertion has expired",
|
|
113
|
+
details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
32
119
|
const spMetadataQuerySchema = z.object({
|
|
33
120
|
providerId: z.string(),
|
|
34
121
|
format: z.enum(["xml", "json"]).default("xml"),
|
|
@@ -154,6 +241,13 @@ const ssoProviderBodySchema = z.object({
|
|
|
154
241
|
})
|
|
155
242
|
.optional(),
|
|
156
243
|
discoveryEndpoint: z.string().optional(),
|
|
244
|
+
skipDiscovery: z
|
|
245
|
+
.boolean()
|
|
246
|
+
.meta({
|
|
247
|
+
description:
|
|
248
|
+
"Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually.",
|
|
249
|
+
})
|
|
250
|
+
.optional(),
|
|
157
251
|
scopes: z
|
|
158
252
|
.array(z.string(), {})
|
|
159
253
|
.meta({
|
|
@@ -573,6 +667,81 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
573
667
|
});
|
|
574
668
|
}
|
|
575
669
|
|
|
670
|
+
let hydratedOIDCConfig: HydratedOIDCConfig | null = null;
|
|
671
|
+
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) {
|
|
672
|
+
try {
|
|
673
|
+
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
674
|
+
issuer: body.issuer,
|
|
675
|
+
existingConfig: {
|
|
676
|
+
discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
|
|
677
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
678
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
679
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
680
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
681
|
+
tokenEndpointAuthentication:
|
|
682
|
+
body.oidcConfig.tokenEndpointAuthentication,
|
|
683
|
+
},
|
|
684
|
+
isTrustedOrigin: ctx.context.isTrustedOrigin,
|
|
685
|
+
});
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (error instanceof DiscoveryError) {
|
|
688
|
+
throw mapDiscoveryErrorToAPIError(error);
|
|
689
|
+
}
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const buildOIDCConfig = () => {
|
|
695
|
+
if (!body.oidcConfig) return null;
|
|
696
|
+
|
|
697
|
+
if (body.oidcConfig.skipDiscovery) {
|
|
698
|
+
return JSON.stringify({
|
|
699
|
+
issuer: body.issuer,
|
|
700
|
+
clientId: body.oidcConfig.clientId,
|
|
701
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
702
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
703
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
704
|
+
tokenEndpointAuthentication:
|
|
705
|
+
body.oidcConfig.tokenEndpointAuthentication ||
|
|
706
|
+
"client_secret_basic",
|
|
707
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
708
|
+
pkce: body.oidcConfig.pkce,
|
|
709
|
+
discoveryEndpoint:
|
|
710
|
+
body.oidcConfig.discoveryEndpoint ||
|
|
711
|
+
`${body.issuer}/.well-known/openid-configuration`,
|
|
712
|
+
mapping: body.oidcConfig.mapping,
|
|
713
|
+
scopes: body.oidcConfig.scopes,
|
|
714
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
715
|
+
overrideUserInfo:
|
|
716
|
+
ctx.body.overrideUserInfo ||
|
|
717
|
+
options?.defaultOverrideUserInfo ||
|
|
718
|
+
false,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (!hydratedOIDCConfig) return null;
|
|
723
|
+
|
|
724
|
+
return JSON.stringify({
|
|
725
|
+
issuer: hydratedOIDCConfig.issuer,
|
|
726
|
+
clientId: body.oidcConfig.clientId,
|
|
727
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
728
|
+
authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
|
|
729
|
+
tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
|
|
730
|
+
tokenEndpointAuthentication:
|
|
731
|
+
hydratedOIDCConfig.tokenEndpointAuthentication,
|
|
732
|
+
jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
|
|
733
|
+
pkce: body.oidcConfig.pkce,
|
|
734
|
+
discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
|
|
735
|
+
mapping: body.oidcConfig.mapping,
|
|
736
|
+
scopes: body.oidcConfig.scopes,
|
|
737
|
+
userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
|
|
738
|
+
overrideUserInfo:
|
|
739
|
+
ctx.body.overrideUserInfo ||
|
|
740
|
+
options?.defaultOverrideUserInfo ||
|
|
741
|
+
false,
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
|
|
576
745
|
const provider = await ctx.context.adapter.create<
|
|
577
746
|
Record<string, any>,
|
|
578
747
|
SSOProvider<O>
|
|
@@ -582,29 +751,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
582
751
|
issuer: body.issuer,
|
|
583
752
|
domain: body.domain,
|
|
584
753
|
domainVerified: false,
|
|
585
|
-
oidcConfig:
|
|
586
|
-
? JSON.stringify({
|
|
587
|
-
issuer: body.issuer,
|
|
588
|
-
clientId: body.oidcConfig.clientId,
|
|
589
|
-
clientSecret: body.oidcConfig.clientSecret,
|
|
590
|
-
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
591
|
-
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
592
|
-
tokenEndpointAuthentication:
|
|
593
|
-
body.oidcConfig.tokenEndpointAuthentication,
|
|
594
|
-
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
595
|
-
pkce: body.oidcConfig.pkce,
|
|
596
|
-
discoveryEndpoint:
|
|
597
|
-
body.oidcConfig.discoveryEndpoint ||
|
|
598
|
-
`${body.issuer}/.well-known/openid-configuration`,
|
|
599
|
-
mapping: body.oidcConfig.mapping,
|
|
600
|
-
scopes: body.oidcConfig.scopes,
|
|
601
|
-
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
602
|
-
overrideUserInfo:
|
|
603
|
-
ctx.body.overrideUserInfo ||
|
|
604
|
-
options?.defaultOverrideUserInfo ||
|
|
605
|
-
false,
|
|
606
|
-
})
|
|
607
|
-
: null,
|
|
754
|
+
oidcConfig: buildOIDCConfig(),
|
|
608
755
|
samlConfig: body.samlConfig
|
|
609
756
|
? JSON.stringify({
|
|
610
757
|
issuer: body.issuer,
|
|
@@ -1661,6 +1808,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1661
1808
|
|
|
1662
1809
|
const { extract } = parsedResponse!;
|
|
1663
1810
|
|
|
1811
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
1812
|
+
clockSkew: options?.saml?.clockSkew,
|
|
1813
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1814
|
+
logger: ctx.context.logger,
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1664
1817
|
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
1665
1818
|
const shouldValidateInResponseTo =
|
|
1666
1819
|
options?.saml?.authnRequestStore ||
|
|
@@ -1685,6 +1838,13 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1685
1838
|
storedRequest = JSON.parse(
|
|
1686
1839
|
verification.value,
|
|
1687
1840
|
) as AuthnRequestRecord;
|
|
1841
|
+
// Validate expiration for database-stored records
|
|
1842
|
+
// Note: Cleanup of expired records is handled automatically by
|
|
1843
|
+
// findVerificationValue, but we still need to check expiration
|
|
1844
|
+
// since the record is returned before cleanup runs
|
|
1845
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
1846
|
+
storedRequest = null;
|
|
1847
|
+
}
|
|
1688
1848
|
} catch {
|
|
1689
1849
|
storedRequest = null;
|
|
1690
1850
|
}
|
|
@@ -2086,6 +2246,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2086
2246
|
|
|
2087
2247
|
const { extract } = parsedResponse!;
|
|
2088
2248
|
|
|
2249
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
2250
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2251
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2252
|
+
logger: ctx.context.logger,
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2089
2255
|
const inResponseToAcs = (extract as any).inResponseTo as
|
|
2090
2256
|
| string
|
|
2091
2257
|
| undefined;
|
|
@@ -2112,6 +2278,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2112
2278
|
storedRequest = JSON.parse(
|
|
2113
2279
|
verification.value,
|
|
2114
2280
|
) as AuthnRequestRecord;
|
|
2281
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2282
|
+
storedRequest = null;
|
|
2283
|
+
}
|
|
2115
2284
|
} catch {
|
|
2116
2285
|
storedRequest = null;
|
|
2117
2286
|
}
|