@facilis.dev/auth 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/api-clients.d.ts +71 -0
- package/dist/api-clients.js +186 -0
- package/dist/crypto.d.ts +31 -0
- package/dist/crypto.js +76 -0
- package/dist/email-verification.d.ts +45 -0
- package/dist/email-verification.js +72 -0
- package/dist/email.d.ts +7 -0
- package/dist/email.js +9 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/password.d.ts +7 -0
- package/dist/password.js +54 -0
- package/dist/roles.d.ts +40 -0
- package/dist/roles.js +46 -0
- package/dist/seed.d.ts +28 -0
- package/dist/seed.js +115 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.js +161 -0
- package/dist/sso/contracts.d.ts +52 -0
- package/dist/sso/contracts.js +8 -0
- package/dist/sso/errors.d.ts +22 -0
- package/dist/sso/errors.js +45 -0
- package/dist/sso/identity.d.ts +41 -0
- package/dist/sso/identity.js +162 -0
- package/dist/sso/index.d.ts +27 -0
- package/dist/sso/index.js +16 -0
- package/dist/sso/login.d.ts +60 -0
- package/dist/sso/login.js +209 -0
- package/dist/sso/oauth2.d.ts +52 -0
- package/dist/sso/oauth2.js +180 -0
- package/dist/sso/oidc.d.ts +60 -0
- package/dist/sso/oidc.js +201 -0
- package/dist/sso/paths.d.ts +14 -0
- package/dist/sso/paths.js +17 -0
- package/dist/sso/pkce.d.ts +6 -0
- package/dist/sso/pkce.js +21 -0
- package/dist/sso/providers.d.ts +100 -0
- package/dist/sso/providers.js +70 -0
- package/dist/sso/saml.d.ts +44 -0
- package/dist/sso/saml.js +159 -0
- package/dist/sso/state.d.ts +34 -0
- package/dist/sso/state.js +91 -0
- package/dist/sso/tables.d.ts +306 -0
- package/dist/sso/tables.js +59 -0
- package/dist/tables.d.ts +769 -0
- package/dist/tables.js +148 -0
- package/package.json +45 -4
- package/src/api-clients.ts +261 -0
- package/src/crypto.ts +101 -0
- package/src/email-verification.ts +107 -0
- package/src/email.ts +9 -0
- package/src/index.ts +63 -0
- package/src/password.ts +74 -0
- package/src/roles.ts +77 -0
- package/src/seed.ts +146 -0
- package/src/session.ts +209 -0
- package/src/sso/contracts.ts +71 -0
- package/src/sso/errors.ts +53 -0
- package/src/sso/identity.ts +220 -0
- package/src/sso/index.ts +105 -0
- package/src/sso/login.ts +296 -0
- package/src/sso/oauth2.ts +235 -0
- package/src/sso/oidc.ts +285 -0
- package/src/sso/paths.ts +21 -0
- package/src/sso/pkce.ts +30 -0
- package/src/sso/providers.ts +176 -0
- package/src/sso/saml.ts +222 -0
- package/src/sso/state.ts +114 -0
- package/src/sso/tables.ts +77 -0
- package/src/tables.ts +214 -0
- package/src/test-support.ts +149 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { SsoError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider resolution against the environment (D-120).
|
|
5
|
+
*
|
|
6
|
+
* The config commits the ENV VAR NAMES that hold secrets; the values never appear in the project.
|
|
7
|
+
* A provider whose secret (or SAML certificate) is missing is unusable: it is hidden from the
|
|
8
|
+
* public list and a start attempt answers `sso_provider_unavailable`. The value itself is never
|
|
9
|
+
* read for logging, compared or echoed.
|
|
10
|
+
*
|
|
11
|
+
* The types here mirror the `auth.sso` section of facilis.config.ts structurally. @facilis.dev/core
|
|
12
|
+
* already validates that config and depends on @facilis.dev/auth, so auth cannot import the core
|
|
13
|
+
* types without a package cycle; a parsed core provider is assignable to these shapes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type SsoProviderKind = "oidc" | "oauth2" | "saml";
|
|
17
|
+
export type SsoTokenAuth = "client_secret_post" | "client_secret_basic" | "none";
|
|
18
|
+
|
|
19
|
+
export interface SsoProviderBase {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly label?: string;
|
|
22
|
+
readonly autoProvision: boolean;
|
|
23
|
+
readonly defaultRole?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OidcProviderConfig extends SsoProviderBase {
|
|
27
|
+
readonly kind: "oidc";
|
|
28
|
+
readonly issuer: string;
|
|
29
|
+
readonly clientId: string;
|
|
30
|
+
readonly clientSecretEnv?: string;
|
|
31
|
+
readonly scopes: readonly string[];
|
|
32
|
+
readonly tokenAuth: SsoTokenAuth;
|
|
33
|
+
readonly assumeEmailVerified: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Oauth2Mapping {
|
|
37
|
+
readonly subject: string;
|
|
38
|
+
readonly email: string;
|
|
39
|
+
readonly emailVerified?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Oauth2ProviderConfig extends SsoProviderBase {
|
|
43
|
+
readonly kind: "oauth2";
|
|
44
|
+
readonly authorizationEndpoint: string;
|
|
45
|
+
readonly tokenEndpoint: string;
|
|
46
|
+
readonly userinfoEndpoint: string;
|
|
47
|
+
readonly clientId: string;
|
|
48
|
+
readonly clientSecretEnv?: string;
|
|
49
|
+
readonly scopes: readonly string[];
|
|
50
|
+
readonly tokenAuth: SsoTokenAuth;
|
|
51
|
+
readonly mapping: Oauth2Mapping;
|
|
52
|
+
readonly assumeEmailVerified: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SamlProviderConfig extends SsoProviderBase {
|
|
56
|
+
readonly kind: "saml";
|
|
57
|
+
readonly entryPoint: string;
|
|
58
|
+
/** SP entity id declared to the IdP, and the expected assertion audience. */
|
|
59
|
+
readonly issuer: string;
|
|
60
|
+
readonly certificateEnv: string;
|
|
61
|
+
readonly emailAttribute: string;
|
|
62
|
+
readonly wantAssertionsSigned: boolean;
|
|
63
|
+
readonly wantResponseSigned: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type SsoProviderConfig = OidcProviderConfig | Oauth2ProviderConfig | SamlProviderConfig;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The identity-policy projection of a provider: exactly the fields identity resolution needs,
|
|
70
|
+
* so the flow never hands the whole config (and its secrets) to the linking code.
|
|
71
|
+
*/
|
|
72
|
+
export function identityPolicy(config: SsoProviderConfig): {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly autoProvision: boolean;
|
|
75
|
+
readonly defaultRole?: string;
|
|
76
|
+
} {
|
|
77
|
+
return {
|
|
78
|
+
id: config.id,
|
|
79
|
+
autoProvision: config.autoProvision,
|
|
80
|
+
...(config.defaultRole === undefined ? {} : { defaultRole: config.defaultRole }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Structural shape of `config.auth.sso` after defineConfig applied its defaults. */
|
|
85
|
+
export interface SsoConfigLike {
|
|
86
|
+
readonly providers: readonly SsoProviderConfig[];
|
|
87
|
+
readonly failureRedirect: string;
|
|
88
|
+
readonly stateTtlMs: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What the login page may see: no secret, no endpoint beyond the framework start URL. */
|
|
92
|
+
export interface PublicSsoProvider {
|
|
93
|
+
readonly id: string;
|
|
94
|
+
readonly label: string;
|
|
95
|
+
readonly kind: SsoProviderKind;
|
|
96
|
+
/** Framework-owned start URL, always relative to the application origin. */
|
|
97
|
+
readonly startUrl: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A provider plus the environment values the flow needs; never serialised. */
|
|
101
|
+
export interface ResolvedSsoProvider {
|
|
102
|
+
readonly config: SsoProviderConfig;
|
|
103
|
+
readonly clientSecret?: string;
|
|
104
|
+
readonly idpCertificate?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const SSO_NAMESPACE = "/api/auth/sso";
|
|
108
|
+
|
|
109
|
+
/** The environment variable whose absence makes a provider unusable, if any. */
|
|
110
|
+
export function missingCredentialEnv(provider: SsoProviderConfig): string | undefined {
|
|
111
|
+
if (provider.kind === "saml") return provider.certificateEnv;
|
|
112
|
+
if (provider.tokenAuth === "none") return undefined;
|
|
113
|
+
return provider.clientSecretEnv;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readEnv(
|
|
117
|
+
name: string | undefined,
|
|
118
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
if (name === undefined) return undefined;
|
|
121
|
+
const value = environment[name];
|
|
122
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when every environment value this provider needs is present. */
|
|
126
|
+
export function isProviderConfigured(
|
|
127
|
+
provider: SsoProviderConfig,
|
|
128
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
129
|
+
): boolean {
|
|
130
|
+
if (provider.kind === "saml") {
|
|
131
|
+
return readEnv(provider.certificateEnv, environment) !== undefined;
|
|
132
|
+
}
|
|
133
|
+
if (provider.tokenAuth === "none") return true;
|
|
134
|
+
return readEnv(provider.clientSecretEnv, environment) !== undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The public list rendered by the login page; unconfigured providers are omitted. */
|
|
138
|
+
export function publicProviderList(
|
|
139
|
+
config: SsoConfigLike,
|
|
140
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
141
|
+
): PublicSsoProvider[] {
|
|
142
|
+
return config.providers
|
|
143
|
+
.filter((provider) => isProviderConfigured(provider, environment))
|
|
144
|
+
.map((provider) => ({
|
|
145
|
+
id: provider.id,
|
|
146
|
+
label: provider.label ?? provider.id,
|
|
147
|
+
kind: provider.kind,
|
|
148
|
+
startUrl: `${SSO_NAMESPACE}/${encodeURIComponent(provider.id)}/start`,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Finds a configured provider and reads its environment secret(s).
|
|
154
|
+
*
|
|
155
|
+
* Unknown provider => `sso_provider_unknown`; a known provider missing its env value =>
|
|
156
|
+
* `sso_provider_unavailable` (the caller logs a warning with the ENV VAR NAME only).
|
|
157
|
+
*/
|
|
158
|
+
export function resolveProvider(
|
|
159
|
+
config: SsoConfigLike,
|
|
160
|
+
id: string,
|
|
161
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
162
|
+
): ResolvedSsoProvider {
|
|
163
|
+
const provider = config.providers.find((candidate) => candidate.id === id);
|
|
164
|
+
if (provider === undefined) throw new SsoError("sso_provider_unknown");
|
|
165
|
+
|
|
166
|
+
if (provider.kind === "saml") {
|
|
167
|
+
const idpCertificate = readEnv(provider.certificateEnv, environment);
|
|
168
|
+
if (idpCertificate === undefined) throw new SsoError("sso_provider_unavailable");
|
|
169
|
+
return { config: provider, idpCertificate };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (provider.tokenAuth === "none") return { config: provider };
|
|
173
|
+
const clientSecret = readEnv(provider.clientSecretEnv, environment);
|
|
174
|
+
if (clientSecret === undefined) throw new SsoError("sso_provider_unavailable");
|
|
175
|
+
return { config: provider, clientSecret };
|
|
176
|
+
}
|
package/src/sso/saml.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { CacheItem, CacheProvider, SamlConfig } from "@node-saml/node-saml";
|
|
2
|
+
|
|
3
|
+
import { encodeBase64 } from "../crypto.ts";
|
|
4
|
+
import type { IdentityClaims } from "./contracts.ts";
|
|
5
|
+
import { SsoError } from "./errors.ts";
|
|
6
|
+
import type { SamlProviderConfig } from "./providers.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SAML 2.0 SP-initiated Web Browser SSO (D-120).
|
|
10
|
+
*
|
|
11
|
+
* The AuthnRequest is built here (HTTP-Redirect, DEFLATE+base64) so the framework owns the
|
|
12
|
+
* request id, the ACS URL and the RelayState. ACS validation is delegated to
|
|
13
|
+
* `@node-saml/node-saml`, imported LAZILY inside this path: a project without SAML never loads
|
|
14
|
+
* (or bundles) its CJS crypto tree. IdP-initiated assertions are not accepted: there is no
|
|
15
|
+
* unsolicited-response surface, and the single-use RelayState is mandatory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CLOCK_SKEW_MS = 5000;
|
|
19
|
+
const NAME_ID_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
|
20
|
+
const PROTOCOL_BINDING_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
21
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
|
|
22
|
+
|
|
23
|
+
export interface BuiltSamlRequest {
|
|
24
|
+
readonly requestId: string;
|
|
25
|
+
readonly redirectUrl: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeXml(value: string): string {
|
|
29
|
+
return value
|
|
30
|
+
.replaceAll("&", "&")
|
|
31
|
+
.replaceAll("<", "<")
|
|
32
|
+
.replaceAll(">", ">")
|
|
33
|
+
.replaceAll('"', """)
|
|
34
|
+
.replaceAll("'", "'");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The AuthnRequest XML (exported for tests; the IdP only ever sees the DEFLATE+base64 form). */
|
|
38
|
+
export function buildAuthnRequestXml(
|
|
39
|
+
provider: SamlProviderConfig,
|
|
40
|
+
requestId: string,
|
|
41
|
+
acsUrl: string,
|
|
42
|
+
issueInstant: string,
|
|
43
|
+
): string {
|
|
44
|
+
return (
|
|
45
|
+
`<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ` +
|
|
46
|
+
`xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${escapeXml(requestId)}" ` +
|
|
47
|
+
`Version="2.0" IssueInstant="${issueInstant}" Destination="${escapeXml(provider.entryPoint)}" ` +
|
|
48
|
+
`AssertionConsumerServiceURL="${escapeXml(acsUrl)}" ProtocolBinding="${PROTOCOL_BINDING_POST}">` +
|
|
49
|
+
`<saml:Issuer>${escapeXml(provider.issuer)}</saml:Issuer>` +
|
|
50
|
+
`<samlp:NameIDPolicy AllowCreate="true" Format="${NAME_ID_FORMAT}"/>` +
|
|
51
|
+
`</samlp:AuthnRequest>`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function deflateRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
|
56
|
+
// node:zlib is a server-only builtin and is loaded with the SAML path, never at module load.
|
|
57
|
+
const { deflateRawSync } = await import("node:zlib");
|
|
58
|
+
return new Uint8Array(deflateRawSync(bytes));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Builds the HTTP-Redirect URL carrying the deflated AuthnRequest and the RelayState. */
|
|
62
|
+
export async function buildSamlRedirect(
|
|
63
|
+
provider: SamlProviderConfig,
|
|
64
|
+
acsUrl: string,
|
|
65
|
+
relayState: string,
|
|
66
|
+
now: number = Date.now(),
|
|
67
|
+
): Promise<BuiltSamlRequest> {
|
|
68
|
+
const requestId = `_${globalThis.crypto.randomUUID()}`;
|
|
69
|
+
const xml = buildAuthnRequestXml(provider, requestId, acsUrl, new Date(now).toISOString());
|
|
70
|
+
const compressed = await deflateRaw(new TextEncoder().encode(xml));
|
|
71
|
+
const url = new URL(provider.entryPoint);
|
|
72
|
+
url.searchParams.set("SAMLRequest", encodeBase64(compressed));
|
|
73
|
+
url.searchParams.set("RelayState", relayState);
|
|
74
|
+
return { requestId, redirectUrl: url.toString() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SamlProfileLike {
|
|
78
|
+
readonly nameID?: unknown;
|
|
79
|
+
readonly issuer?: unknown;
|
|
80
|
+
[attribute: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SamlValidator {
|
|
84
|
+
validatePostResponseAsync(
|
|
85
|
+
container: Record<string, string>,
|
|
86
|
+
): Promise<{ profile: SamlProfileLike | null; loggedOut: boolean }>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type SamlValidatorFactory = (config: SamlConfig) => SamlValidator | Promise<SamlValidator>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A cache provider whose only entry is the request id of THIS attempt.
|
|
93
|
+
*
|
|
94
|
+
* `validateInResponseTo: "always"` (D-147) requires the response to carry the InResponseTo of the
|
|
95
|
+
* AuthnRequest this framework issued and to match the single-use state row; a response without
|
|
96
|
+
* it (IdP-initiated / unsolicited, or a replayed assertion) is rejected by node-saml.
|
|
97
|
+
*/
|
|
98
|
+
function singleUseRequestCache(requestId: string | null): CacheProvider {
|
|
99
|
+
let remaining = requestId;
|
|
100
|
+
const take = async (key: string | null): Promise<string | null> => {
|
|
101
|
+
if (key === null || remaining === null || key !== remaining) return null;
|
|
102
|
+
const value = remaining;
|
|
103
|
+
remaining = null;
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
async saveAsync(): Promise<CacheItem | null> {
|
|
108
|
+
return null;
|
|
109
|
+
},
|
|
110
|
+
async getAsync(key: string): Promise<string | null> {
|
|
111
|
+
return key === remaining ? remaining : null;
|
|
112
|
+
},
|
|
113
|
+
async removeAsync(key: string | null): Promise<string | null> {
|
|
114
|
+
return take(key);
|
|
115
|
+
},
|
|
116
|
+
async consumeAsync(key: string): Promise<string | null> {
|
|
117
|
+
return take(key);
|
|
118
|
+
},
|
|
119
|
+
async saveIfAbsentAsync(): Promise<boolean> {
|
|
120
|
+
return false;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toSamlConfig(
|
|
126
|
+
provider: SamlProviderConfig,
|
|
127
|
+
certificate: string,
|
|
128
|
+
callbackUrl: string,
|
|
129
|
+
requestId: string | null,
|
|
130
|
+
): SamlConfig {
|
|
131
|
+
return {
|
|
132
|
+
callbackUrl,
|
|
133
|
+
entryPoint: provider.entryPoint,
|
|
134
|
+
issuer: provider.issuer,
|
|
135
|
+
audience: provider.issuer,
|
|
136
|
+
idpCert: certificate,
|
|
137
|
+
wantAssertionsSigned: provider.wantAssertionsSigned,
|
|
138
|
+
wantAuthnResponseSigned: provider.wantResponseSigned,
|
|
139
|
+
acceptedClockSkewMs: CLOCK_SKEW_MS,
|
|
140
|
+
// The library's enum is a runtime value; the string literal is the same member.
|
|
141
|
+
validateInResponseTo: "always" as SamlConfig["validateInResponseTo"],
|
|
142
|
+
cacheProvider: singleUseRequestCache(requestId),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function defaultSamlFactory(config: SamlConfig): Promise<SamlValidator> {
|
|
147
|
+
const { SAML } = await import("@node-saml/node-saml");
|
|
148
|
+
return new SAML(config) as unknown as SamlValidator;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function asString(value: unknown): string | undefined {
|
|
152
|
+
if (typeof value === "string" && value.trim().length > 0) return value;
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const first = value.find((entry) => typeof entry === "string" && entry.length > 0);
|
|
155
|
+
return typeof first === "string" ? first : undefined;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Case-insensitive attribute lookup; `nameid` means the NameID itself. */
|
|
161
|
+
function readAttribute(profile: SamlProfileLike, attribute: string): string | undefined {
|
|
162
|
+
const wanted = attribute.toLowerCase();
|
|
163
|
+
if (wanted === "nameid") return asString(profile.nameID);
|
|
164
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
165
|
+
if (key.toLowerCase() !== wanted) continue;
|
|
166
|
+
const found = asString(value);
|
|
167
|
+
if (found !== undefined) return found;
|
|
168
|
+
}
|
|
169
|
+
for (const fallback of ["email", "mail", "emailaddress"]) {
|
|
170
|
+
const found = asString(profile[fallback]);
|
|
171
|
+
if (found !== undefined) return found;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ValidateSamlInput {
|
|
177
|
+
readonly provider: SamlProviderConfig;
|
|
178
|
+
/** The IdP signing certificate (PEM or base64 DER). Never logged. */
|
|
179
|
+
readonly certificate: string;
|
|
180
|
+
readonly callbackUrl: string;
|
|
181
|
+
/** The base64 `SAMLResponse` form field. */
|
|
182
|
+
readonly samlResponse: string;
|
|
183
|
+
/** The AuthnRequest id stored in the consumed state row. */
|
|
184
|
+
readonly requestId: string | null;
|
|
185
|
+
/** Test seam: replace the lazily imported validator. */
|
|
186
|
+
readonly validatorFactory?: SamlValidatorFactory;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validates an ACS POST and returns the asserted identity.
|
|
191
|
+
*
|
|
192
|
+
* Signature, recipient, audience and time-window checks are node-saml's; a rejected response
|
|
193
|
+
* (unsigned, tampered, wrong recipient/audience, expired) becomes `sso_exchange_failed`, never
|
|
194
|
+
* a library message. A signed assertion is the IdP asserting the email, so SAML has no separate
|
|
195
|
+
* `email_verified` flag and treats the address as verified.
|
|
196
|
+
*/
|
|
197
|
+
export async function validateSamlResponse(input: ValidateSamlInput): Promise<IdentityClaims> {
|
|
198
|
+
const config = toSamlConfig(
|
|
199
|
+
input.provider,
|
|
200
|
+
input.certificate,
|
|
201
|
+
input.callbackUrl,
|
|
202
|
+
input.requestId,
|
|
203
|
+
);
|
|
204
|
+
const factory = input.validatorFactory ?? defaultSamlFactory;
|
|
205
|
+
let result: Awaited<ReturnType<SamlValidator["validatePostResponseAsync"]>>;
|
|
206
|
+
try {
|
|
207
|
+
const validator = await factory(config);
|
|
208
|
+
result = await validator.validatePostResponseAsync({ SAMLResponse: input.samlResponse });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new SsoError("sso_exchange_failed", "SAML response rejected", { cause: error });
|
|
211
|
+
}
|
|
212
|
+
if (result.profile === null || result.loggedOut) {
|
|
213
|
+
throw new SsoError("sso_exchange_failed", "SAML response has no assertion");
|
|
214
|
+
}
|
|
215
|
+
const profile = result.profile;
|
|
216
|
+
const email = readAttribute(profile, input.provider.emailAttribute);
|
|
217
|
+
if (email === undefined || !EMAIL_PATTERN.test(email)) {
|
|
218
|
+
throw new SsoError("sso_identity_invalid", "SAML assertion has no email");
|
|
219
|
+
}
|
|
220
|
+
const subject = asString(profile.nameID) ?? email;
|
|
221
|
+
return { provider: input.provider.id, subject, email, emailVerified: true };
|
|
222
|
+
}
|
package/src/sso/state.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { eq, lt } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import { randomToken } from "../crypto.ts";
|
|
4
|
+
import type { SystemRunner } from "./contracts.ts";
|
|
5
|
+
import { SsoError } from "./errors.ts";
|
|
6
|
+
import { authSsoStates, type AuthSsoStateRow } from "./tables.ts";
|
|
7
|
+
|
|
8
|
+
/** Contract cap on the post-login path (same-origin only; D-120). */
|
|
9
|
+
export const MAX_REDIRECT_TO = 2048;
|
|
10
|
+
const STATE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,256}$/;
|
|
11
|
+
const STATE_TABLES = ["auth_sso_states"] as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates a post-login redirect: a same-origin absolute path only.
|
|
15
|
+
*
|
|
16
|
+
* Rejecting `//host`, a backslash and control characters closes the open-redirect hole: the value
|
|
17
|
+
* becomes the `Location` of a 302 and the user must never leave the application origin.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeRedirectTo(value: unknown): string | null {
|
|
20
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_REDIRECT_TO)
|
|
21
|
+
return null;
|
|
22
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
23
|
+
if (value.includes("\\")) return null;
|
|
24
|
+
// Whitespace and control characters have no business in an origin-relative path; browsers
|
|
25
|
+
// trim some of them, so refuse instead of relying on normalisation.
|
|
26
|
+
for (const character of value) {
|
|
27
|
+
const code = character.codePointAt(0) ?? 0;
|
|
28
|
+
if (code <= 0x20 || code === 0x7f) return null;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CreateSsoStateInput {
|
|
34
|
+
/** Explicit token (SAML builds the AuthnRequest, whose RelayState is the state, before insert). */
|
|
35
|
+
readonly state?: string;
|
|
36
|
+
readonly provider: string;
|
|
37
|
+
/** `auth.sso.stateTtlMs`. */
|
|
38
|
+
readonly ttlMs: number;
|
|
39
|
+
readonly redirectTo: string | null;
|
|
40
|
+
readonly nonce?: string;
|
|
41
|
+
readonly codeVerifier?: string;
|
|
42
|
+
readonly requestId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stateElevation(operation: string) {
|
|
46
|
+
return {
|
|
47
|
+
actor: "system:auth:sso" as const,
|
|
48
|
+
operation,
|
|
49
|
+
reason: "The SSO login attempt is framework-owned state written before a session exists.",
|
|
50
|
+
tables: STATE_TABLES,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates one single-use state row (32 random bytes, base64url) inside the audited system
|
|
56
|
+
* elevation. Expired rows are pruned on the same write, so a start request also cleans up.
|
|
57
|
+
*/
|
|
58
|
+
export async function createSsoState(
|
|
59
|
+
system: SystemRunner,
|
|
60
|
+
input: CreateSsoStateInput,
|
|
61
|
+
now: number = Date.now(),
|
|
62
|
+
): Promise<AuthSsoStateRow> {
|
|
63
|
+
const state = input.state ?? randomToken(32);
|
|
64
|
+
if (!STATE_TOKEN_PATTERN.test(state)) throw new SsoError("sso_internal_error", "bad state token");
|
|
65
|
+
const createdAt = new Date(now);
|
|
66
|
+
const expiresAt = new Date(now + input.ttlMs);
|
|
67
|
+
|
|
68
|
+
const row = await system(stateElevation("auth:sso:state:create"), async (db) => {
|
|
69
|
+
await db.delete(authSsoStates).where(lt(authSsoStates.expiresAt, createdAt));
|
|
70
|
+
const inserted = await db
|
|
71
|
+
.insert(authSsoStates)
|
|
72
|
+
.values({
|
|
73
|
+
state,
|
|
74
|
+
provider: input.provider,
|
|
75
|
+
nonce: input.nonce ?? null,
|
|
76
|
+
codeVerifier: input.codeVerifier ?? null,
|
|
77
|
+
requestId: input.requestId ?? null,
|
|
78
|
+
redirectTo: input.redirectTo,
|
|
79
|
+
createdAt,
|
|
80
|
+
expiresAt,
|
|
81
|
+
})
|
|
82
|
+
.returning();
|
|
83
|
+
return inserted[0] ?? null;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (row === null) throw new SsoError("sso_internal_error");
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Consumes a state row atomically (`DELETE ... RETURNING`) and returns it once.
|
|
92
|
+
*
|
|
93
|
+
* A missing row (already used, unknown, or never issued) and an expired row are both
|
|
94
|
+
* `sso_state_invalid`: the caller only learns "this attempt is not valid".
|
|
95
|
+
*/
|
|
96
|
+
export async function consumeSsoState(
|
|
97
|
+
system: SystemRunner,
|
|
98
|
+
state: string,
|
|
99
|
+
now: number = Date.now(),
|
|
100
|
+
): Promise<AuthSsoStateRow> {
|
|
101
|
+
if (!STATE_TOKEN_PATTERN.test(state)) throw new SsoError("sso_state_invalid");
|
|
102
|
+
|
|
103
|
+
const row = await system(stateElevation("auth:sso:state:consume"), async (db) => {
|
|
104
|
+
const deleted = await db
|
|
105
|
+
.delete(authSsoStates)
|
|
106
|
+
.where(eq(authSsoStates.state, state))
|
|
107
|
+
.returning();
|
|
108
|
+
return deleted[0] ?? null;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (row === null) throw new SsoError("sso_state_invalid");
|
|
112
|
+
if (row.expiresAt.getTime() <= now) throw new SsoError("sso_state_invalid");
|
|
113
|
+
return row;
|
|
114
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { pgPolicy, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
import { rlsForTable, tenantColumn } from "@facilis.dev/db";
|
|
5
|
+
|
|
6
|
+
import { users } from "../tables.ts";
|
|
7
|
+
|
|
8
|
+
const identitiesRls = rlsForTable({ tenantColumn: "tenant_id" });
|
|
9
|
+
const statesRls = rlsForTable({ tenantColumn: "tenant_id" });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SSO identity links (D-120, SPEC §5.3): one row per `(provider, subject)` pair.
|
|
13
|
+
*
|
|
14
|
+
* The identity key is the IdP's stable subject, never the email: a user who changes their email
|
|
15
|
+
* at the IdP keeps the link. `email` is the address reported at the last sign-in, for operators.
|
|
16
|
+
*
|
|
17
|
+
* RLS matches `users`: restrictive tenant isolation AND-ed with a permissive "tenant scope
|
|
18
|
+
* present" policy, because Postgres denies by default when no permissive policy applies.
|
|
19
|
+
*/
|
|
20
|
+
export const authIdentities = pgTable(
|
|
21
|
+
"auth_identities",
|
|
22
|
+
{
|
|
23
|
+
provider: text("provider").notNull(),
|
|
24
|
+
subject: text("subject").notNull(),
|
|
25
|
+
userId: uuid("user_id")
|
|
26
|
+
.notNull()
|
|
27
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
28
|
+
email: text("email").notNull(),
|
|
29
|
+
tenantId: tenantColumn(),
|
|
30
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
31
|
+
lastLoginAt: timestamp("last_login_at").notNull().defaultNow(),
|
|
32
|
+
},
|
|
33
|
+
(table) => [
|
|
34
|
+
primaryKey({ columns: [table.provider, table.subject] }),
|
|
35
|
+
...identitiesRls.policies,
|
|
36
|
+
pgPolicy("auth_identities_tenant_access", {
|
|
37
|
+
for: "all",
|
|
38
|
+
using: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
39
|
+
withCheck: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
).enableRLS();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One SSO login attempt (D-120): single-use state plus whatever the protocol needs to finish
|
|
46
|
+
* (`nonce`/`code_verifier` for OIDC, `request_id` for SAML, `redirect_to` for the browser).
|
|
47
|
+
*
|
|
48
|
+
* The row is written and consumed inside the audited `ctx.system` elevation, so RLS is not the
|
|
49
|
+
* load-bearing check here, but every scoped table ships the standard policies (SPEC §8.3).
|
|
50
|
+
*/
|
|
51
|
+
export const authSsoStates = pgTable(
|
|
52
|
+
"auth_sso_states",
|
|
53
|
+
{
|
|
54
|
+
state: text("state").primaryKey(),
|
|
55
|
+
provider: text("provider").notNull(),
|
|
56
|
+
nonce: text("nonce"),
|
|
57
|
+
codeVerifier: text("code_verifier"),
|
|
58
|
+
requestId: text("request_id"),
|
|
59
|
+
redirectTo: text("redirect_to"),
|
|
60
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
61
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
62
|
+
tenantId: tenantColumn(),
|
|
63
|
+
},
|
|
64
|
+
() => [
|
|
65
|
+
...statesRls.policies,
|
|
66
|
+
pgPolicy("auth_sso_states_tenant_access", {
|
|
67
|
+
for: "all",
|
|
68
|
+
using: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
69
|
+
withCheck: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
).enableRLS();
|
|
73
|
+
|
|
74
|
+
export type AuthIdentityRow = typeof authIdentities.$inferSelect;
|
|
75
|
+
export type NewAuthIdentityRow = typeof authIdentities.$inferInsert;
|
|
76
|
+
export type AuthSsoStateRow = typeof authSsoStates.$inferSelect;
|
|
77
|
+
export type NewAuthSsoStateRow = typeof authSsoStates.$inferInsert;
|