@yunsoft/yuncms-api 0.1.5 → 0.1.6
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/package.json +9 -3
- package/src/app.js +93 -50
- package/src/error-response.js +1 -0
- package/src/extensions/runtime.js +44 -9
- package/src/extensions/scheduler.js +288 -0
- package/src/external-auth/config.js +204 -0
- package/src/external-auth/providers.js +440 -0
- package/src/mcp.js +364 -0
- package/src/rate-limit.js +52 -22
- package/src/routes/auth.js +132 -55
- package/src/routes/schema.js +46 -9
- package/src/server.js +75 -52
- package/studio-dist/assets/index-B30LRjIx.js +9 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-CT3jNSGp.js +0 -9
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const DRIVER_NAMES = new Set(['oidc', 'oauth2', 'ldap', 'saml']);
|
|
2
|
+
|
|
3
|
+
function configError(message) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.code = 'INVALID_AUTH_PROVIDER_CONFIG';
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readString(env, name, fallback = '') {
|
|
10
|
+
const value = env[name];
|
|
11
|
+
return value == null ? fallback : String(value).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readBoolean(env, name, fallback = false) {
|
|
15
|
+
const value = env[name];
|
|
16
|
+
if (value == null || value === '') return fallback;
|
|
17
|
+
if (value === true || value === 'true' || value === '1') return true;
|
|
18
|
+
if (value === false || value === 'false' || value === '0') return false;
|
|
19
|
+
throw configError(`${name} must be true or false`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireString(env, name) {
|
|
23
|
+
const value = readString(env, name);
|
|
24
|
+
if (!value) throw configError(`${name} is required`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readUrl(env, name, { protocols = ['https:'], required = true } = {}) {
|
|
29
|
+
const raw = required ? requireString(env, name) : readString(env, name);
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
let url;
|
|
32
|
+
try { url = new URL(raw); } catch { throw configError(`${name} must be a valid URL`); }
|
|
33
|
+
if (!protocols.includes(url.protocol)) {
|
|
34
|
+
throw configError(`${name} must use ${protocols.join(' or ')}`);
|
|
35
|
+
}
|
|
36
|
+
return url.toString().replace(/\/$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeProviderId(value) {
|
|
40
|
+
const id = String(value ?? '').trim().toLowerCase();
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) {
|
|
42
|
+
throw configError(`Invalid provider id: ${value}`);
|
|
43
|
+
}
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function envPrefix(id) {
|
|
48
|
+
return `AUTH_PROVIDER_${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readClaims(env, prefix) {
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
subject: readString(env, `${prefix}_SUBJECT_CLAIM`, 'sub'),
|
|
54
|
+
email: readString(env, `${prefix}_EMAIL_CLAIM`, 'email'),
|
|
55
|
+
emailVerified: readString(env, `${prefix}_EMAIL_VERIFIED_CLAIM`, 'email_verified'),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readPolicy(env, prefix) {
|
|
60
|
+
const jit = readBoolean(env, `${prefix}_JIT`, false);
|
|
61
|
+
const linkByVerifiedEmail = readBoolean(env, `${prefix}_LINK_BY_VERIFIED_EMAIL`, false);
|
|
62
|
+
const defaultRole = readString(env, `${prefix}_DEFAULT_ROLE`) || null;
|
|
63
|
+
if (jit && !defaultRole) throw configError(`${prefix}_DEFAULT_ROLE is required when JIT is enabled`);
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
jit,
|
|
66
|
+
defaultRole,
|
|
67
|
+
linkByVerifiedEmail,
|
|
68
|
+
allowAdminLink: readBoolean(env, `${prefix}_ALLOW_ADMIN_LINK`, false),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readOidc(env, prefix) {
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
issuer: readUrl(env, `${prefix}_ISSUER`),
|
|
75
|
+
clientId: requireString(env, `${prefix}_CLIENT_ID`),
|
|
76
|
+
clientSecret: requireString(env, `${prefix}_CLIENT_SECRET`),
|
|
77
|
+
scopes: readString(env, `${prefix}_SCOPES`, 'openid profile email'),
|
|
78
|
+
claims: readClaims(env, prefix),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readOauth2(env, prefix) {
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
issuer: readUrl(env, `${prefix}_ISSUER`),
|
|
85
|
+
authorizationEndpoint: readUrl(env, `${prefix}_AUTHORIZATION_ENDPOINT`),
|
|
86
|
+
tokenEndpoint: readUrl(env, `${prefix}_TOKEN_ENDPOINT`),
|
|
87
|
+
userinfoEndpoint: readUrl(env, `${prefix}_USERINFO_ENDPOINT`),
|
|
88
|
+
clientId: requireString(env, `${prefix}_CLIENT_ID`),
|
|
89
|
+
clientSecret: requireString(env, `${prefix}_CLIENT_SECRET`),
|
|
90
|
+
clientAuth: readString(env, `${prefix}_CLIENT_AUTH`, 'post').toLowerCase(),
|
|
91
|
+
scopes: readString(env, `${prefix}_SCOPES`, 'profile email'),
|
|
92
|
+
claims: readClaims(env, prefix),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertLdapAttribute(value, prefix, label) {
|
|
97
|
+
if (!/^[A-Za-z][A-Za-z0-9-]{0,63}$/.test(value)) {
|
|
98
|
+
throw configError(`${prefix} ${label} is invalid`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readLdap(env, prefix) {
|
|
104
|
+
const allowInsecure = readBoolean(env, `${prefix}_ALLOW_INSECURE`, false);
|
|
105
|
+
const url = readUrl(env, `${prefix}_URL`, { protocols: allowInsecure ? ['ldap:', 'ldaps:'] : ['ldaps:'] });
|
|
106
|
+
const userAttribute = assertLdapAttribute(
|
|
107
|
+
readString(env, `${prefix}_USER_ATTRIBUTE`, 'uid'),
|
|
108
|
+
prefix,
|
|
109
|
+
'user attribute',
|
|
110
|
+
);
|
|
111
|
+
const subjectAttribute = assertLdapAttribute(
|
|
112
|
+
readString(env, `${prefix}_SUBJECT_ATTRIBUTE`, 'entryUUID'),
|
|
113
|
+
prefix,
|
|
114
|
+
'subject attribute',
|
|
115
|
+
);
|
|
116
|
+
const emailAttribute = assertLdapAttribute(
|
|
117
|
+
readString(env, `${prefix}_EMAIL_ATTRIBUTE`, 'mail'),
|
|
118
|
+
prefix,
|
|
119
|
+
'email attribute',
|
|
120
|
+
);
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
url,
|
|
123
|
+
baseDn: requireString(env, `${prefix}_BASE_DN`),
|
|
124
|
+
bindDn: readString(env, `${prefix}_BIND_DN`) || null,
|
|
125
|
+
bindPassword: readString(env, `${prefix}_BIND_PASSWORD`) || null,
|
|
126
|
+
userAttribute,
|
|
127
|
+
subjectAttribute,
|
|
128
|
+
emailAttribute,
|
|
129
|
+
allowInsecure,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeCertificate(value, name) {
|
|
134
|
+
const certificate = String(value ?? '').replace(/\\n/g, '\n').trim();
|
|
135
|
+
if (!certificate.includes('BEGIN CERTIFICATE') || !certificate.includes('END CERTIFICATE')) {
|
|
136
|
+
throw configError(`${name} must contain a PEM certificate`);
|
|
137
|
+
}
|
|
138
|
+
return certificate;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSaml(env, prefix) {
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
entryPoint: readUrl(env, `${prefix}_ENTRY_POINT`),
|
|
144
|
+
issuer: requireString(env, `${prefix}_ISSUER`),
|
|
145
|
+
idpCert: normalizeCertificate(requireString(env, `${prefix}_IDP_CERT`), `${prefix}_IDP_CERT`),
|
|
146
|
+
idpIssuer: readString(env, `${prefix}_IDP_ISSUER`) || null,
|
|
147
|
+
emailAttribute: readString(env, `${prefix}_EMAIL_ATTRIBUTE`, 'email'),
|
|
148
|
+
clockSkewMs: Number(readString(env, `${prefix}_CLOCK_SKEW_MS`, '5000')),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function providerConfig(env, id) {
|
|
153
|
+
const prefix = envPrefix(id);
|
|
154
|
+
const driver = requireString(env, `${prefix}_DRIVER`).toLowerCase();
|
|
155
|
+
if (!DRIVER_NAMES.has(driver)) throw configError(`${prefix}_DRIVER must be oidc, oauth2, ldap or saml`);
|
|
156
|
+
const common = {
|
|
157
|
+
id,
|
|
158
|
+
label: readString(env, `${prefix}_LABEL`, id),
|
|
159
|
+
driver,
|
|
160
|
+
policy: readPolicy(env, prefix),
|
|
161
|
+
};
|
|
162
|
+
const protocol = driver === 'oidc' ? readOidc(env, prefix)
|
|
163
|
+
: driver === 'oauth2' ? readOauth2(env, prefix)
|
|
164
|
+
: driver === 'ldap' ? readLdap(env, prefix)
|
|
165
|
+
: readSaml(env, prefix);
|
|
166
|
+
if (driver === 'oauth2' && !['post', 'basic'].includes(protocol.clientAuth)) {
|
|
167
|
+
throw configError(`${prefix}_CLIENT_AUTH must be post or basic`);
|
|
168
|
+
}
|
|
169
|
+
if (driver === 'saml' && (!Number.isInteger(protocol.clockSkewMs) || protocol.clockSkewMs < 0 || protocol.clockSkewMs > 120_000)) {
|
|
170
|
+
throw configError(`${prefix}_CLOCK_SKEW_MS must be an integer between 0 and 120000`);
|
|
171
|
+
}
|
|
172
|
+
return Object.freeze({ ...common, ...protocol });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function loadExternalAuthConfig(env = process.env) {
|
|
176
|
+
const rawIds = readString(env, 'AUTH_PROVIDERS');
|
|
177
|
+
const ids = rawIds ? rawIds.split(',').map(normalizeProviderId).filter(Boolean) : [];
|
|
178
|
+
if (new Set(ids).size !== ids.length) throw configError('AUTH_PROVIDERS contains duplicate provider ids');
|
|
179
|
+
const providers = ids.map((id) => providerConfig(env, id));
|
|
180
|
+
const browserEnabled = providers.some((provider) => provider.driver !== 'ldap');
|
|
181
|
+
const stateSecret = readString(env, 'AUTH_STATE_SECRET') || null;
|
|
182
|
+
if (browserEnabled && (!stateSecret || stateSecret.length < 32)) {
|
|
183
|
+
throw configError('AUTH_STATE_SECRET must contain at least 32 characters when browser auth providers are enabled');
|
|
184
|
+
}
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
enabled: providers.length > 0,
|
|
187
|
+
stateSecret,
|
|
188
|
+
providers: Object.freeze(providers),
|
|
189
|
+
publicProviders: Object.freeze(providers.map(({ id, label, driver }) => Object.freeze({ id, label, driver }))),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function findExternalAuthProvider(config, id) {
|
|
194
|
+
const providerId = normalizeProviderId(id);
|
|
195
|
+
const provider = config?.providers?.find((candidate) => candidate.id === providerId);
|
|
196
|
+
if (!provider) {
|
|
197
|
+
const error = new Error('Authentication provider not found');
|
|
198
|
+
error.code = 'AUTH_PROVIDER_NOT_FOUND';
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
return provider;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { normalizeProviderId };
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { SAML } from '@node-saml/node-saml';
|
|
2
|
+
import {
|
|
3
|
+
createExternalAuthState,
|
|
4
|
+
hashExternalAuthState,
|
|
5
|
+
} from '@yunsoft/yuncms-core';
|
|
6
|
+
import { Client as LdapClient, Filter } from 'ldapts';
|
|
7
|
+
import * as oauth from 'openid-client';
|
|
8
|
+
|
|
9
|
+
import { findExternalAuthProvider } from './config.js';
|
|
10
|
+
|
|
11
|
+
const SAML_CACHE_PROVIDER = '__saml_cache__';
|
|
12
|
+
const BROWSER_HANDOFF_PROVIDER = 'handoff';
|
|
13
|
+
const BROWSER_HANDOFF_TTL_MS = 60_000;
|
|
14
|
+
const SAML_REQUEST_TTL_MS = 5 * 60_000;
|
|
15
|
+
|
|
16
|
+
function authError(code, message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = code;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function callbackUrl(publicUrl, provider) {
|
|
23
|
+
return `${publicUrl}/auth/callback/${encodeURIComponent(provider.id)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scalarClaim(value) {
|
|
27
|
+
if (Array.isArray(value)) return value.length === 1 ? value[0] : null;
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function booleanClaim(value) {
|
|
32
|
+
if (value === true || value === 'true' || value === 1 || value === '1') return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeLdapSubject(value) {
|
|
37
|
+
const scalar = scalarClaim(value);
|
|
38
|
+
if (Buffer.isBuffer(scalar)) {
|
|
39
|
+
if (scalar.byteLength === 0) return null;
|
|
40
|
+
return `hex:${scalar.toString('hex')}`;
|
|
41
|
+
}
|
|
42
|
+
if (scalar instanceof Uint8Array) {
|
|
43
|
+
if (scalar.byteLength === 0) return null;
|
|
44
|
+
return `hex:${Buffer.from(scalar).toString('hex')}`;
|
|
45
|
+
}
|
|
46
|
+
if (typeof scalar !== 'string') return null;
|
|
47
|
+
const normalized = scalar.trim();
|
|
48
|
+
return normalized || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mappedIdentity(provider, profile, { defaultEmailVerified = false } = {}) {
|
|
52
|
+
const subject = scalarClaim(profile?.[provider.claims?.subject ?? 'sub']);
|
|
53
|
+
const email = scalarClaim(profile?.[provider.claims?.email ?? 'email']);
|
|
54
|
+
const emailVerifiedValue = profile?.[provider.claims?.emailVerified ?? 'email_verified'];
|
|
55
|
+
if (typeof subject !== 'string' || !subject.trim()) {
|
|
56
|
+
throw authError('EXTERNAL_IDENTITY_INVALID', 'External provider did not return a usable subject identifier');
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
subject: subject.trim(),
|
|
60
|
+
email: typeof email === 'string' && email.trim() ? email.trim() : null,
|
|
61
|
+
emailVerified: emailVerifiedValue == null ? defaultEmailVerified : booleanClaim(emailVerifiedValue),
|
|
62
|
+
profile,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function currentCallbackUrl(publicUrl, provider, query) {
|
|
67
|
+
const url = new URL(callbackUrl(publicUrl, provider));
|
|
68
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const entry of value) url.searchParams.append(key, String(entry));
|
|
71
|
+
} else if (value != null) {
|
|
72
|
+
url.searchParams.set(key, String(value));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class MysqlSamlCacheProvider {
|
|
79
|
+
constructor(database) {
|
|
80
|
+
this.database = database;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async saveAsync(key, value) {
|
|
84
|
+
const createdAt = Date.now();
|
|
85
|
+
const expiresAt = new Date(createdAt + SAML_REQUEST_TTL_MS);
|
|
86
|
+
await this.database.query(
|
|
87
|
+
`INSERT INTO yuncms_auth_transactions
|
|
88
|
+
(id, provider, state_hash, redirect_target, metadata, expires_at)
|
|
89
|
+
VALUES (?, ?, ?, '/', ?, ?)`,
|
|
90
|
+
[
|
|
91
|
+
crypto.randomUUID(),
|
|
92
|
+
SAML_CACHE_PROVIDER,
|
|
93
|
+
hashExternalAuthState(key),
|
|
94
|
+
JSON.stringify({ value: String(value) }),
|
|
95
|
+
expiresAt,
|
|
96
|
+
],
|
|
97
|
+
);
|
|
98
|
+
return { value: String(value), createdAt };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getAsync(key) {
|
|
102
|
+
if (key == null) return null;
|
|
103
|
+
const [rows] = await this.database.query(
|
|
104
|
+
`SELECT metadata
|
|
105
|
+
FROM yuncms_auth_transactions
|
|
106
|
+
WHERE provider = ? AND state_hash = ? AND used_at IS NULL
|
|
107
|
+
AND expires_at > CURRENT_TIMESTAMP(3)
|
|
108
|
+
LIMIT 1`,
|
|
109
|
+
[SAML_CACHE_PROVIDER, hashExternalAuthState(key)],
|
|
110
|
+
);
|
|
111
|
+
let metadata = rows[0]?.metadata ?? null;
|
|
112
|
+
if (typeof metadata === 'string') {
|
|
113
|
+
try { metadata = JSON.parse(metadata); } catch { metadata = null; }
|
|
114
|
+
}
|
|
115
|
+
return typeof metadata?.value === 'string' ? metadata.value : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async removeAsync(key) {
|
|
119
|
+
if (key == null) return null;
|
|
120
|
+
const value = await this.getAsync(key);
|
|
121
|
+
if (value == null) return null;
|
|
122
|
+
await this.database.query(
|
|
123
|
+
`UPDATE yuncms_auth_transactions
|
|
124
|
+
SET used_at = CURRENT_TIMESTAMP(3)
|
|
125
|
+
WHERE provider = ? AND state_hash = ? AND used_at IS NULL`,
|
|
126
|
+
[SAML_CACHE_PROVIDER, hashExternalAuthState(key)],
|
|
127
|
+
);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function oauthClientAuthentication(provider) {
|
|
133
|
+
return provider.clientAuth === 'basic'
|
|
134
|
+
? oauth.ClientSecretBasic(provider.clientSecret)
|
|
135
|
+
: oauth.ClientSecretPost(provider.clientSecret);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class ExternalAuthProviderRegistry {
|
|
139
|
+
constructor({ config, publicUrl, database, logger = console } = {}) {
|
|
140
|
+
this.config = config;
|
|
141
|
+
this.publicUrl = String(publicUrl ?? '').replace(/\/$/, '');
|
|
142
|
+
this.database = database;
|
|
143
|
+
this.logger = logger;
|
|
144
|
+
this.clientCache = new Map();
|
|
145
|
+
this.samlCache = new Map();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
publicProviders() {
|
|
149
|
+
return this.config?.publicProviders ?? [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
provider(id) {
|
|
153
|
+
return findExternalAuthProvider(this.config, id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #oidcConfiguration(provider) {
|
|
157
|
+
if (!this.clientCache.has(provider.id)) {
|
|
158
|
+
this.clientCache.set(provider.id, oauth.discovery(
|
|
159
|
+
new URL(provider.issuer),
|
|
160
|
+
provider.clientId,
|
|
161
|
+
provider.clientSecret,
|
|
162
|
+
oauth.ClientSecretPost(provider.clientSecret),
|
|
163
|
+
));
|
|
164
|
+
}
|
|
165
|
+
return this.clientCache.get(provider.id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#oauthConfiguration(provider) {
|
|
169
|
+
if (!this.clientCache.has(provider.id)) {
|
|
170
|
+
const configuration = new oauth.Configuration(
|
|
171
|
+
{
|
|
172
|
+
issuer: provider.issuer,
|
|
173
|
+
authorization_endpoint: provider.authorizationEndpoint,
|
|
174
|
+
token_endpoint: provider.tokenEndpoint,
|
|
175
|
+
},
|
|
176
|
+
provider.clientId,
|
|
177
|
+
{ client_secret: provider.clientSecret },
|
|
178
|
+
oauthClientAuthentication(provider),
|
|
179
|
+
);
|
|
180
|
+
this.clientCache.set(provider.id, configuration);
|
|
181
|
+
}
|
|
182
|
+
return this.clientCache.get(provider.id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#saml(provider) {
|
|
186
|
+
if (!this.samlCache.has(provider.id)) {
|
|
187
|
+
const instance = new SAML({
|
|
188
|
+
callbackUrl: callbackUrl(this.publicUrl, provider),
|
|
189
|
+
entryPoint: provider.entryPoint,
|
|
190
|
+
issuer: provider.issuer,
|
|
191
|
+
idpCert: provider.idpCert,
|
|
192
|
+
...(provider.idpIssuer ? { idpIssuer: provider.idpIssuer } : {}),
|
|
193
|
+
acceptedClockSkewMs: provider.clockSkewMs,
|
|
194
|
+
validateInResponseTo: 'always',
|
|
195
|
+
requestIdExpirationPeriodMs: SAML_REQUEST_TTL_MS,
|
|
196
|
+
cacheProvider: new MysqlSamlCacheProvider(this.database),
|
|
197
|
+
wantAssertionsSigned: true,
|
|
198
|
+
wantAuthnResponseSigned: true,
|
|
199
|
+
signatureAlgorithm: 'sha256',
|
|
200
|
+
});
|
|
201
|
+
this.samlCache.set(provider.id, instance);
|
|
202
|
+
}
|
|
203
|
+
return this.samlCache.get(provider.id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async begin(service, providerId, { redirectTarget = '/' } = {}) {
|
|
207
|
+
const provider = this.provider(providerId);
|
|
208
|
+
if (provider.driver === 'ldap') throw authError('AUTH_PROVIDER_FLOW_MISMATCH', 'LDAP providers use password login');
|
|
209
|
+
|
|
210
|
+
if (provider.driver === 'saml') {
|
|
211
|
+
const relayState = createExternalAuthState();
|
|
212
|
+
await service.beginTransaction({
|
|
213
|
+
provider: provider.id,
|
|
214
|
+
state: relayState,
|
|
215
|
+
redirectTarget,
|
|
216
|
+
metadata: { driver: 'saml' },
|
|
217
|
+
});
|
|
218
|
+
const url = await this.#saml(provider).getAuthorizeUrlAsync(relayState, undefined, {});
|
|
219
|
+
return { provider, url: new URL(url) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const state = oauth.randomState();
|
|
223
|
+
const codeVerifier = oauth.randomPKCECodeVerifier();
|
|
224
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
225
|
+
const redirectUri = callbackUrl(this.publicUrl, provider);
|
|
226
|
+
const secret = { codeVerifier };
|
|
227
|
+
const parameters = {
|
|
228
|
+
redirect_uri: redirectUri,
|
|
229
|
+
scope: provider.scopes,
|
|
230
|
+
code_challenge: codeChallenge,
|
|
231
|
+
code_challenge_method: 'S256',
|
|
232
|
+
state,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
let configuration;
|
|
236
|
+
if (provider.driver === 'oidc') {
|
|
237
|
+
const nonce = oauth.randomNonce();
|
|
238
|
+
secret.nonce = nonce;
|
|
239
|
+
parameters.nonce = nonce;
|
|
240
|
+
configuration = await this.#oidcConfiguration(provider);
|
|
241
|
+
} else {
|
|
242
|
+
configuration = this.#oauthConfiguration(provider);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await service.beginTransaction({
|
|
246
|
+
provider: provider.id,
|
|
247
|
+
state,
|
|
248
|
+
secret,
|
|
249
|
+
redirectTarget,
|
|
250
|
+
metadata: { driver: provider.driver },
|
|
251
|
+
});
|
|
252
|
+
return { provider, url: oauth.buildAuthorizationUrl(configuration, parameters) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async #completeOidc(service, provider, query, requestInfo) {
|
|
256
|
+
const state = String(query?.state ?? '');
|
|
257
|
+
if (!state) throw authError('INVALID_AUTH_TRANSACTION', 'OIDC state is required');
|
|
258
|
+
const transaction = await service.consumeTransaction({ provider: provider.id, state });
|
|
259
|
+
const configuration = await this.#oidcConfiguration(provider);
|
|
260
|
+
const tokens = await oauth.authorizationCodeGrant(
|
|
261
|
+
configuration,
|
|
262
|
+
currentCallbackUrl(this.publicUrl, provider, query),
|
|
263
|
+
{
|
|
264
|
+
pkceCodeVerifier: transaction.secret?.codeVerifier,
|
|
265
|
+
expectedState: state,
|
|
266
|
+
expectedNonce: transaction.secret?.nonce,
|
|
267
|
+
idTokenExpected: true,
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
const claims = tokens.claims();
|
|
271
|
+
if (!claims?.sub) throw authError('EXTERNAL_IDENTITY_INVALID', 'OIDC ID token did not contain a subject');
|
|
272
|
+
let profile = claims;
|
|
273
|
+
if (tokens.access_token) {
|
|
274
|
+
try {
|
|
275
|
+
profile = await oauth.fetchUserInfo(configuration, tokens.access_token, claims.sub);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
this.logger?.warn?.('OIDC userinfo request failed; using validated ID token claims', { provider: provider.id, code: error?.code ?? null });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const identity = mappedIdentity(provider, profile);
|
|
281
|
+
const result = await service.completeLogin({
|
|
282
|
+
provider: provider.id,
|
|
283
|
+
...identity,
|
|
284
|
+
policy: provider.policy,
|
|
285
|
+
...requestInfo,
|
|
286
|
+
});
|
|
287
|
+
return { result, redirectTarget: transaction.redirectTarget };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async #completeOauth2(service, provider, query, requestInfo) {
|
|
291
|
+
const state = String(query?.state ?? '');
|
|
292
|
+
if (!state) throw authError('INVALID_AUTH_TRANSACTION', 'OAuth state is required');
|
|
293
|
+
const transaction = await service.consumeTransaction({ provider: provider.id, state });
|
|
294
|
+
const configuration = this.#oauthConfiguration(provider);
|
|
295
|
+
const tokens = await oauth.authorizationCodeGrant(
|
|
296
|
+
configuration,
|
|
297
|
+
currentCallbackUrl(this.publicUrl, provider, query),
|
|
298
|
+
{
|
|
299
|
+
pkceCodeVerifier: transaction.secret?.codeVerifier,
|
|
300
|
+
expectedState: state,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
if (!tokens.access_token) throw authError('EXTERNAL_IDENTITY_INVALID', 'OAuth provider did not return an access token');
|
|
304
|
+
const response = await oauth.fetchProtectedResource(
|
|
305
|
+
configuration,
|
|
306
|
+
tokens.access_token,
|
|
307
|
+
new URL(provider.userinfoEndpoint),
|
|
308
|
+
'GET',
|
|
309
|
+
);
|
|
310
|
+
if (!response.ok) throw authError('EXTERNAL_USERINFO_FAILED', 'OAuth user profile request failed');
|
|
311
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
312
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
313
|
+
throw authError('EXTERNAL_USERINFO_FAILED', 'OAuth user profile response must be JSON');
|
|
314
|
+
}
|
|
315
|
+
const profile = await response.json();
|
|
316
|
+
const identity = mappedIdentity(provider, profile);
|
|
317
|
+
const result = await service.completeLogin({
|
|
318
|
+
provider: provider.id,
|
|
319
|
+
...identity,
|
|
320
|
+
policy: provider.policy,
|
|
321
|
+
...requestInfo,
|
|
322
|
+
});
|
|
323
|
+
return { result, redirectTarget: transaction.redirectTarget };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async #completeSaml(service, provider, body, requestInfo) {
|
|
327
|
+
const relayState = String(body?.RelayState ?? '');
|
|
328
|
+
const response = String(body?.SAMLResponse ?? '');
|
|
329
|
+
if (!relayState || !response) throw authError('INVALID_AUTH_TRANSACTION', 'SAML response and RelayState are required');
|
|
330
|
+
const validation = await this.#saml(provider).validatePostResponseAsync({ SAMLResponse: response });
|
|
331
|
+
if (validation.loggedOut || !validation.profile?.nameID) {
|
|
332
|
+
throw authError('EXTERNAL_IDENTITY_INVALID', 'SAML response did not contain an authenticated identity');
|
|
333
|
+
}
|
|
334
|
+
const transaction = await service.consumeTransaction({ provider: provider.id, state: relayState });
|
|
335
|
+
const profile = validation.profile;
|
|
336
|
+
const rawEmail = scalarClaim(profile[provider.emailAttribute] ?? profile.email ?? profile.mail);
|
|
337
|
+
const result = await service.completeLogin({
|
|
338
|
+
provider: provider.id,
|
|
339
|
+
subject: profile.nameID,
|
|
340
|
+
email: typeof rawEmail === 'string' ? rawEmail : null,
|
|
341
|
+
emailVerified: true,
|
|
342
|
+
profile,
|
|
343
|
+
policy: provider.policy,
|
|
344
|
+
...requestInfo,
|
|
345
|
+
});
|
|
346
|
+
return { result, redirectTarget: transaction.redirectTarget };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async completeBrowser(service, providerId, { query = {}, body = {}, ip = null, userAgent = null } = {}) {
|
|
350
|
+
const provider = this.provider(providerId);
|
|
351
|
+
const requestInfo = { ip, userAgent };
|
|
352
|
+
if (provider.driver === 'oidc') return this.#completeOidc(service, provider, query, requestInfo);
|
|
353
|
+
if (provider.driver === 'oauth2') return this.#completeOauth2(service, provider, query, requestInfo);
|
|
354
|
+
if (provider.driver === 'saml') return this.#completeSaml(service, provider, body, requestInfo);
|
|
355
|
+
throw authError('AUTH_PROVIDER_FLOW_MISMATCH', 'LDAP providers do not use browser callbacks');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async loginLdap(service, providerId, { username, password, ip = null, userAgent = null } = {}) {
|
|
359
|
+
const provider = this.provider(providerId);
|
|
360
|
+
if (provider.driver !== 'ldap') throw authError('AUTH_PROVIDER_FLOW_MISMATCH', 'Provider does not support LDAP password login');
|
|
361
|
+
const login = String(username ?? '').trim();
|
|
362
|
+
const secret = typeof password === 'string' ? password : '';
|
|
363
|
+
if (!login || login.length > 255 || !secret || secret.length > 4096) {
|
|
364
|
+
throw authError('INVALID_CREDENTIALS', 'Invalid username or password');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const client = new LdapClient({ url: provider.url, timeout: 10_000, connectTimeout: 10_000 });
|
|
368
|
+
try {
|
|
369
|
+
if (provider.bindDn) await client.bind(provider.bindDn, provider.bindPassword ?? '');
|
|
370
|
+
const filter = `(${provider.userAttribute}=${Filter.escape(login)})`;
|
|
371
|
+
const { searchEntries } = await client.search(provider.baseDn, {
|
|
372
|
+
scope: 'sub',
|
|
373
|
+
filter,
|
|
374
|
+
sizeLimit: 2,
|
|
375
|
+
timeLimit: 10,
|
|
376
|
+
attributes: [...new Set([provider.subjectAttribute, provider.emailAttribute])],
|
|
377
|
+
});
|
|
378
|
+
if (searchEntries.length !== 1) throw authError('INVALID_CREDENTIALS', 'Invalid username or password');
|
|
379
|
+
const entry = searchEntries[0];
|
|
380
|
+
const subject = normalizeLdapSubject(entry[provider.subjectAttribute]);
|
|
381
|
+
if (!subject) {
|
|
382
|
+
throw authError(
|
|
383
|
+
'EXTERNAL_IDENTITY_INVALID',
|
|
384
|
+
`LDAP entry did not return configured stable subject attribute: ${provider.subjectAttribute}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
await client.bind(entry.dn, secret);
|
|
388
|
+
const rawEmail = scalarClaim(entry[provider.emailAttribute]);
|
|
389
|
+
return service.completeLogin({
|
|
390
|
+
provider: provider.id,
|
|
391
|
+
subject,
|
|
392
|
+
email: typeof rawEmail === 'string' ? rawEmail : null,
|
|
393
|
+
emailVerified: true,
|
|
394
|
+
profile: {
|
|
395
|
+
preferred_username: login,
|
|
396
|
+
subject_attribute: provider.subjectAttribute,
|
|
397
|
+
},
|
|
398
|
+
policy: provider.policy,
|
|
399
|
+
ip,
|
|
400
|
+
userAgent,
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error?.code?.startsWith?.('EXTERNAL_')) throw error;
|
|
404
|
+
throw authError('INVALID_CREDENTIALS', 'Invalid username or password');
|
|
405
|
+
} finally {
|
|
406
|
+
await client.unbind().catch(() => {});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async createBrowserHandoff(service, result, redirectTarget) {
|
|
411
|
+
const authCode = createExternalAuthState(32);
|
|
412
|
+
await service.beginTransaction({
|
|
413
|
+
provider: BROWSER_HANDOFF_PROVIDER,
|
|
414
|
+
state: authCode,
|
|
415
|
+
secret: result,
|
|
416
|
+
redirectTarget,
|
|
417
|
+
metadata: { kind: 'browser-handoff' },
|
|
418
|
+
ttlMs: BROWSER_HANDOFF_TTL_MS,
|
|
419
|
+
});
|
|
420
|
+
const target = new URL(redirectTarget, 'https://local.yuncms.invalid');
|
|
421
|
+
target.searchParams.set('auth_code', authCode);
|
|
422
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async exchangeBrowserHandoff(service, authCode) {
|
|
426
|
+
const transaction = await service.consumeTransaction({ provider: BROWSER_HANDOFF_PROVIDER, state: authCode });
|
|
427
|
+
if (!transaction.secret || typeof transaction.secret !== 'object') {
|
|
428
|
+
throw authError('INVALID_AUTH_TRANSACTION', 'Authentication handoff is invalid');
|
|
429
|
+
}
|
|
430
|
+
return transaction.secret;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export {
|
|
435
|
+
BROWSER_HANDOFF_PROVIDER,
|
|
436
|
+
BROWSER_HANDOFF_TTL_MS,
|
|
437
|
+
MysqlSamlCacheProvider,
|
|
438
|
+
normalizeLdapSubject,
|
|
439
|
+
SAML_REQUEST_TTL_MS,
|
|
440
|
+
};
|