@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3
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/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +17 -17
- package/docs/audit.md +16 -16
- package/docs/authenticators.md +60 -27
- package/docs/authorization.md +16 -16
- package/docs/cors.md +29 -29
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +66 -4
- package/docs/firewall.md +57 -24
- package/docs/headers.md +30 -30
- package/docs/index.md +12 -4
- package/docs/keycloak.md +818 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +236 -122
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +136 -85
- package/docs/totp.md +105 -64
- package/docs/webauthn.md +90 -87
- package/docs/webhooks.md +3 -3
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
2
|
import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
|
|
3
3
|
import { Nodefony, RequestContext } from "nodefony";
|
|
4
|
-
import {
|
|
4
|
+
import { authenticatedUser } from "@nodefony/user";
|
|
5
5
|
//#region nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
|
|
6
6
|
/**
|
|
7
7
|
* Authenticator realtime des identités résolues par le **firewall** — équivalent
|
|
@@ -66,7 +66,7 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
66
66
|
}
|
|
67
67
|
/** Une identité authentifiée a-t-elle été résolue (par le firewall) au handshake ? */
|
|
68
68
|
supports(_handshake) {
|
|
69
|
-
return
|
|
69
|
+
return authenticatedUser() !== null;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Promeut l'identité déjà résolue (ALS) en jeton realtime — 0 lecture base.
|
|
@@ -76,15 +76,40 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
76
76
|
* filet défensif fail-closed → le hub ferme la socket en 4001).
|
|
77
77
|
*/
|
|
78
78
|
async authenticate(_handshake) {
|
|
79
|
-
const user =
|
|
80
|
-
if (
|
|
79
|
+
const user = authenticatedUser();
|
|
80
|
+
if (user === null) throw new AuthenticationError("Invalid realtime session");
|
|
81
81
|
const issued = readFirewallToken();
|
|
82
|
-
const type =
|
|
83
|
-
const revalidate =
|
|
82
|
+
const type = issuedType(issued);
|
|
83
|
+
const revalidate = this.#revalidatorFor(user, issued, type);
|
|
84
84
|
const scopes = issued ? issued.getScopes() : void 0;
|
|
85
85
|
return new UserRealtimeToken(user, revalidate, type, scopes);
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
+
* Le revalidateur de l'identité que le firewall a posée sur la requête
|
|
89
|
+
* COURANTE (ALS), pour toute connexion longue — socket WebSocket ou flux SSE.
|
|
90
|
+
*
|
|
91
|
+
* Une seule règle décide qu'une identité est morte, quel que soit le
|
|
92
|
+
* transport qui la porte : c'est celle-ci, et le hub comme le noyau HTTP
|
|
93
|
+
* l'appellent au lieu de la recopier.
|
|
94
|
+
*
|
|
95
|
+
* @returns `null` pour un anonyme (rien à révoquer), sinon la fonction qui
|
|
96
|
+
* dit si l'identité est TOUJOURS valable — fail-closed : `false` quand elle
|
|
97
|
+
* ne peut pas le prouver.
|
|
98
|
+
*/
|
|
99
|
+
currentRevalidator() {
|
|
100
|
+
const user = authenticatedUser();
|
|
101
|
+
if (user === null) return null;
|
|
102
|
+
const issued = readFirewallToken();
|
|
103
|
+
return this.#revalidatorFor(user, issued, issuedType(issued));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Choisit la preuve de révocation selon le mode d'authentification : la
|
|
107
|
+
* session se relit, un jeton porteur se borne par `exp` et la denylist.
|
|
108
|
+
*/
|
|
109
|
+
#revalidatorFor(user, issued, type) {
|
|
110
|
+
return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
88
113
|
* Re-validation d'une identité portée par un **jeton** (JWT, clé API, OAuth) :
|
|
89
114
|
* rejoue les trois conditions qui tuent un jeton, sans jamais parler de session.
|
|
90
115
|
*
|
|
@@ -129,6 +154,13 @@ const EMPTY_CLAIMS = Object.freeze({});
|
|
|
129
154
|
function secondsToMs(value) {
|
|
130
155
|
return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : null;
|
|
131
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Le mode d'authentification du jeton du firewall. Absent (zone historique) →
|
|
159
|
+
* le mode le plus strict, la session : un repli sûr, jamais permissif.
|
|
160
|
+
*/
|
|
161
|
+
function issuedType(issued) {
|
|
162
|
+
return typeof issued?.type === "string" && issued.type ? issued.type : "session";
|
|
163
|
+
}
|
|
132
164
|
/** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
|
|
133
165
|
function readFirewallToken() {
|
|
134
166
|
return RequestContext.get()?.token;
|
|
@@ -161,14 +193,5 @@ function buildSessionRevalidator(identifier) {
|
|
|
161
193
|
}
|
|
162
194
|
};
|
|
163
195
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Vrai si `user` est un utilisateur AUTHENTIFIÉ (≠ anonyme). Le firewall pose
|
|
166
|
-
* `token.getUser()` dans l'ALS : soit l'`IUser` réel, soit `anonymousUser`
|
|
167
|
-
* (singleton). Tout ce qui n'est pas l'anonyme et porte un `identifier` est
|
|
168
|
-
* une identité valide.
|
|
169
|
-
*/
|
|
170
|
-
function isAuthenticatedUser(user) {
|
|
171
|
-
return !!user && user !== anonymousUser && typeof user.identifier === "string";
|
|
172
|
-
}
|
|
173
196
|
//#endregion
|
|
174
197
|
export { FirewallRealtimeAuthenticator, FirewallRealtimeAuthenticator as default };
|
|
@@ -24,22 +24,32 @@ var UserPasswordAuthenticator = class {
|
|
|
24
24
|
#verifier = null;
|
|
25
25
|
#resolveVerifier;
|
|
26
26
|
#throttler;
|
|
27
|
+
#transport;
|
|
27
28
|
/**
|
|
28
29
|
* @param resolveVerifier - résolution lazy de la source de vérification
|
|
29
30
|
* (typiquement `container.get("users")`) — appelée au premier login.
|
|
30
31
|
* @param throttler - limiteur de tentatives (backoff NIST), `null` = désactivé.
|
|
32
|
+
* @param transport - politique du transport des secrets, `null` = aucune.
|
|
31
33
|
*/
|
|
32
|
-
constructor(resolveVerifier, throttler = null) {
|
|
34
|
+
constructor(resolveVerifier, throttler = null, transport = null) {
|
|
33
35
|
this.#resolveVerifier = resolveVerifier;
|
|
34
36
|
this.#throttler = throttler;
|
|
37
|
+
this.#transport = transport;
|
|
35
38
|
}
|
|
36
39
|
/** La requête porte-t-elle un en-tête `Authorization: Basic ...` ? */
|
|
37
40
|
supports(context) {
|
|
38
41
|
const auth = context.request?.headers.authorization;
|
|
39
42
|
return typeof auth === "string" && BASIC_SCHEME.test(auth);
|
|
40
43
|
}
|
|
41
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Décode l'enveloppe Basic — un contenu malformé donne un credential vide
|
|
46
|
+
* (échec uniforme).
|
|
47
|
+
*
|
|
48
|
+
* @throws InsecureTransportError (403) — en production, en-tête reçu hors
|
|
49
|
+
* TLS : refusé avant même d'être décodé.
|
|
50
|
+
*/
|
|
42
51
|
createToken(context) {
|
|
52
|
+
this.#transport?.assert(context, "basic");
|
|
43
53
|
const auth = context.request?.headers.authorization;
|
|
44
54
|
const decoded = Buffer.from(auth.replace(BASIC_SCHEME, ""), "base64").toString("utf8");
|
|
45
55
|
const sep = decoded.indexOf(":");
|
|
@@ -86,7 +96,7 @@ var UserPasswordAuthenticator = class {
|
|
|
86
96
|
onFailure(_context, _error) {
|
|
87
97
|
return Promise.resolve();
|
|
88
98
|
}
|
|
89
|
-
/** Challenge RFC
|
|
99
|
+
/** Challenge RFC 9110 §11 posé par le firewall sur les 401 de la zone. */
|
|
90
100
|
challenge() {
|
|
91
101
|
return "Basic realm=\"nodefony\", charset=\"UTF-8\"";
|
|
92
102
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileProviderRoleMapping } from "../oauth/providerRoles.js";
|
|
1
2
|
import { AnonymousAuthenticator } from "./AnonymousAuthenticator.js";
|
|
2
3
|
import { SessionAuthenticator } from "./SessionAuthenticator.js";
|
|
3
4
|
import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
|
|
@@ -26,11 +27,12 @@ function listAuthenticatorFactories() {
|
|
|
26
27
|
registerAuthenticatorFactory("anonymous", () => new AnonymousAuthenticator());
|
|
27
28
|
registerAuthenticatorFactory("userpassword", ({ container }) => {
|
|
28
29
|
const throttler = container.get("loginThrottler") ?? null;
|
|
30
|
+
const transport = container.get("credentialTransport") ?? null;
|
|
29
31
|
return new UserPasswordAuthenticator(() => {
|
|
30
32
|
const verifier = container.get("users");
|
|
31
33
|
if (!verifier) throw new Error("UserPasswordAuthenticator: aucun service \"users\" (IPasswordVerifier) dans le container — enregistrer un UserService au boot de l'application.");
|
|
32
34
|
return verifier;
|
|
33
|
-
}, throttler);
|
|
35
|
+
}, throttler, transport);
|
|
34
36
|
});
|
|
35
37
|
registerAuthenticatorFactory("session", ({ container }) => {
|
|
36
38
|
return new SessionAuthenticator(() => {
|
|
@@ -50,7 +52,12 @@ registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
|
|
|
50
52
|
subjectMapping: i.subjectMapping
|
|
51
53
|
})),
|
|
52
54
|
subjectPolicy: rs.subjectPolicy,
|
|
53
|
-
ephemeralRoles: rs.ephemeralRoles
|
|
55
|
+
ephemeralRoles: rs.ephemeralRoles,
|
|
56
|
+
oauthProviders: Object.entries(config.oauth2.providers).flatMap(([name, p]) => p.issuer ? [{
|
|
57
|
+
name,
|
|
58
|
+
issuer: p.issuer,
|
|
59
|
+
roleMapping: compileProviderRoleMapping(p)
|
|
60
|
+
}] : [])
|
|
54
61
|
});
|
|
55
62
|
});
|
|
56
63
|
registerAuthenticatorFactory("apikey", ({ container, config }) => {
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { bearerToken };
|
|
1
|
+
export { bearerToken } from "nodefony";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isFrameworkZone } from "nodefony";
|
|
2
|
+
//#region nodefony/src/boot/publicRoutesNotice.ts
|
|
3
|
+
/**
|
|
4
|
+
* Le point d'attention « routes métier publiques » du bilan de démarrage.
|
|
5
|
+
*
|
|
6
|
+
* Hors d'une zone du pare-feu, une requête n'est jamais authentifiée : son
|
|
7
|
+
* identité n'est pas résolue, et une route métier qu'on croyait protégée
|
|
8
|
+
* répond à n'importe qui. Les seules zones présentes sont alors les aires du
|
|
9
|
+
* framework (`/nodefony/*`) ; on le DIT au démarrage, avec la recette.
|
|
10
|
+
*
|
|
11
|
+
* La classification « applicative ou framework » n'est pas recopiée : elle
|
|
12
|
+
* vient du cœur (`isFrameworkZone`), qui l'applique aussi au tableau des zones.
|
|
13
|
+
*
|
|
14
|
+
* @param zones - zones montées (`firewall.describe().zones`).
|
|
15
|
+
* @returns le point à déclarer, ou `null` si une zone applicative existe.
|
|
16
|
+
*/
|
|
17
|
+
function publicRoutesNotice(zones) {
|
|
18
|
+
if (zones.some((z) => !isFrameworkZone(z))) return null;
|
|
19
|
+
return {
|
|
20
|
+
code: "FIREWALL_PUBLIC_ROUTES",
|
|
21
|
+
level: "warning",
|
|
22
|
+
message: "Tes routes métier sont PUBLIQUES — aucune zone de pare-feu ne les couvre",
|
|
23
|
+
fix: "use(\"@nodefony/security\", { areas: { main: { pattern: \"^/api\", authenticators: [\"session\"] } } })"
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { publicRoutesNotice };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loginPagePath, safeRedirectPath } from "nodefony";
|
|
2
|
+
//#region nodefony/src/entryPoint.ts
|
|
3
|
+
/**
|
|
4
|
+
* Dit si une requête refusée est la NAVIGATION d'un navigateur — le seul cas
|
|
5
|
+
* où conduire à la page de connexion aide, au lieu de casser un client.
|
|
6
|
+
*
|
|
7
|
+
* `GET` ou `HEAD`, `Accept` qui demande du HTML, et `Sec-Fetch-Mode: navigate`
|
|
8
|
+
* quand l'en-tête est présent : un `fetch()` d'une page porte `cors` ou
|
|
9
|
+
* `same-origin` même s'il accepte du HTML, et recevrait une page de connexion
|
|
10
|
+
* à la place de ses données.
|
|
11
|
+
*
|
|
12
|
+
* @param request - la requête refusée
|
|
13
|
+
* @returns `true` pour une navigation
|
|
14
|
+
*/
|
|
15
|
+
function isBrowserNavigation(request) {
|
|
16
|
+
const method = request.method?.toUpperCase();
|
|
17
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
18
|
+
if (request.accept?.toLowerCase().includes("text/html") !== true) return false;
|
|
19
|
+
return request.fetchMode === void 0 || request.fetchMode.toLowerCase() === "navigate";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* L'adresse de la page de connexion pour un visiteur refusé :
|
|
23
|
+
* `<entryPoint>?from=<chemin demandé>`, composée par `loginPagePath` (cœur).
|
|
24
|
+
* Le chemin demandé repasse par `safeRedirectPath` — la seule garde
|
|
25
|
+
* anti-redirection ouverte du framework, que la page de connexion appliquera
|
|
26
|
+
* de nouveau au retour.
|
|
27
|
+
*
|
|
28
|
+
* @param entryPoint - chemin de la page de connexion (vérifié au boot)
|
|
29
|
+
* @param request - la requête refusée
|
|
30
|
+
* @returns l'adresse du `Location`, ou `null` quand ce n'est pas une
|
|
31
|
+
* navigation, ou quand la page demandée EST la page de connexion
|
|
32
|
+
*/
|
|
33
|
+
function entryPointLocation(entryPoint, request) {
|
|
34
|
+
if (!isBrowserNavigation(request)) return null;
|
|
35
|
+
if (request.path.split("?", 1)[0] === entryPoint.split("?", 1)[0]) return null;
|
|
36
|
+
return loginPagePath(safeRedirectPath(request.path), entryPoint);
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { entryPointLocation, isBrowserNavigation };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { postTokenRequest } from "./oauth2Client.js";
|
|
2
|
+
import { discoverAuthorizationServer } from "./metadata.js";
|
|
3
|
+
//#region nodefony/src/oauth/clientCredentials.ts
|
|
4
|
+
/**
|
|
5
|
+
* Obtient un jeton d'accès pour un programme, par le flux `client_credentials`
|
|
6
|
+
* (RFC 6749 §4.4), auprès d'un émetteur découvert (RFC 8414).
|
|
7
|
+
*
|
|
8
|
+
* C'est la voie de l'appelant MACHINE : il s'authentifie avec son propre
|
|
9
|
+
* secret, et le jeton rendu le désigne lui, pas un utilisateur. La même
|
|
10
|
+
* authentification de client que l'échange de code ({@link postTokenRequest}) —
|
|
11
|
+
* une seule implémentation, pour qu'aucune règle ne diverge d'un flux à l'autre.
|
|
12
|
+
*
|
|
13
|
+
* @param request - émetteur, identité du client, portées et ressource.
|
|
14
|
+
* @returns Les jetons rendus ; `accessToken()` est celui à présenter en `Bearer`.
|
|
15
|
+
* @throws Error - secret vide, émetteur invalide ou injoignable, métadonnées discordantes.
|
|
16
|
+
* @throws OAuth2RequestError - le serveur a refusé (`invalid_client` : secret
|
|
17
|
+
* faux ; `unauthorized_client` : le client n'a pas droit à ce flux).
|
|
18
|
+
*/
|
|
19
|
+
async function requestClientCredentialsToken(request) {
|
|
20
|
+
if (request.clientSecret.length === 0) throw new Error(`client « ${request.clientId} » : secret vide — le flux client_credentials n'existe que pour un client confidentiel (RFC 6749 §4.4).`);
|
|
21
|
+
const server = await discoverAuthorizationServer(request.issuer, {
|
|
22
|
+
discoveryUrl: request.discoveryUrl,
|
|
23
|
+
...request.fetch ? { fetch: request.fetch } : {},
|
|
24
|
+
...request.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
|
|
25
|
+
});
|
|
26
|
+
const body = new URLSearchParams();
|
|
27
|
+
body.set("grant_type", "client_credentials");
|
|
28
|
+
if (request.scopes !== void 0 && request.scopes.length > 0) body.set("scope", request.scopes.join(" "));
|
|
29
|
+
if (request.resource !== void 0) body.set("resource", request.resource);
|
|
30
|
+
return postTokenRequest({
|
|
31
|
+
tokenEndpoint: server.tokenEndpoint,
|
|
32
|
+
clientId: request.clientId,
|
|
33
|
+
clientSecret: request.clientSecret,
|
|
34
|
+
clientAuthMethod: request.clientAuthMethod ?? "client_secret_basic",
|
|
35
|
+
fetch: request.fetch,
|
|
36
|
+
timeoutMs: request.timeoutMs
|
|
37
|
+
}, body);
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { requestClientCredentialsToken };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
|
-
import { canonicalIssuer,
|
|
2
|
+
import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
|
|
3
3
|
//#region nodefony/src/oauth/metadata.ts
|
|
4
4
|
/**
|
|
5
5
|
* **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
|
|
@@ -22,6 +22,19 @@ import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nod
|
|
|
22
22
|
const DISCOVERY_TIMEOUT_MS = 1e4;
|
|
23
23
|
/** Au-delà, le document n'est plus un document de métadonnées — on refuse de lire. */
|
|
24
24
|
const MAX_METADATA_BYTES = 1048576;
|
|
25
|
+
/**
|
|
26
|
+
* Rend un échec de transport LISIBLE : `fetch` (undici) ne dit que
|
|
27
|
+
* `fetch failed` et range la vraie raison dans `cause` — certificat refusé,
|
|
28
|
+
* connexion refusée, nom introuvable. Sans elle, « métadonnées introuvables »
|
|
29
|
+
* envoie chercher un émetteur absent quand c'est la confiance TLS qui manque.
|
|
30
|
+
*/
|
|
31
|
+
function describeTransportFailure(error) {
|
|
32
|
+
if (!(error instanceof Error)) return String(error);
|
|
33
|
+
const cause = error.cause;
|
|
34
|
+
if (!(cause instanceof Error)) return error.message;
|
|
35
|
+
const code = cause.code;
|
|
36
|
+
return typeof code === "string" ? `${error.message} (${code} : ${cause.message})` : `${error.message} (${cause.message})`;
|
|
37
|
+
}
|
|
25
38
|
async function fetchMetadataDocument(url, options) {
|
|
26
39
|
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
27
40
|
headers: {
|
|
@@ -34,7 +47,7 @@ async function fetchMetadataDocument(url, options) {
|
|
|
34
47
|
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
|
|
35
48
|
return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
|
|
36
49
|
}
|
|
37
|
-
function requireEndpoint(document, field, issuer) {
|
|
50
|
+
function requireEndpoint(document, field, issuer, backChannelOf) {
|
|
38
51
|
const value = document[field];
|
|
39
52
|
if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
|
|
40
53
|
let url;
|
|
@@ -43,10 +56,20 @@ function requireEndpoint(document, field, issuer) {
|
|
|
43
56
|
} catch {
|
|
44
57
|
throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
|
|
45
58
|
}
|
|
46
|
-
if (url.protocol
|
|
59
|
+
if (!(backChannelOf === void 0 ? url.protocol === "https:" : isBackChannelTransportAllowed(url, backChannelOf))) throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https` + (backChannelOf === void 0 ? "." : ` (ou en http sur l'origine de discoveryUrl « ${backChannelOf} »).`));
|
|
47
60
|
return value;
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
63
|
+
* Point d'entrée FACULTATIF : absent ⇒ `null` ; présent, il obéit aux mêmes
|
|
64
|
+
* règles qu'un point requis (URL https) — un serveur qui publie un point
|
|
65
|
+
* malformé se signale à la découverte, pas au premier clic de l'utilisateur.
|
|
66
|
+
*/
|
|
67
|
+
function optionalEndpoint(document, field, issuer) {
|
|
68
|
+
const value = document[field];
|
|
69
|
+
if (value === void 0 || value === null) return null;
|
|
70
|
+
return requireEndpoint(document, field, issuer);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
50
73
|
* Interroge un serveur d'autorisation et rend ses points d'entrée.
|
|
51
74
|
*
|
|
52
75
|
* Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
|
|
@@ -56,30 +79,34 @@ function requireEndpoint(document, field, issuer) {
|
|
|
56
79
|
* d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
|
|
57
80
|
*
|
|
58
81
|
* @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
|
|
59
|
-
* @param options - transport injectable
|
|
82
|
+
* @param options - transport injectable, délai d'attente, adresse de découverte.
|
|
60
83
|
* @returns Les points d'entrée, prêts pour `OAuth2Client`.
|
|
61
84
|
* @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
|
|
62
85
|
*/
|
|
63
86
|
async function discoverAuthorizationServer(rawIssuer, options = {}) {
|
|
64
87
|
const issuer = canonicalIssuer(rawIssuer);
|
|
65
88
|
const failures = [];
|
|
66
|
-
|
|
89
|
+
const discoveryUrl = options.discoveryUrl;
|
|
90
|
+
for (const candidate of metadataCandidateUrls(issuer, discoveryUrl)) {
|
|
67
91
|
let document;
|
|
68
92
|
try {
|
|
69
93
|
document = await fetchMetadataDocument(candidate, options);
|
|
70
94
|
} catch (error) {
|
|
71
|
-
failures.push(
|
|
95
|
+
failures.push(describeTransportFailure(error));
|
|
72
96
|
continue;
|
|
73
97
|
}
|
|
74
|
-
const identity = validateIssuerMetadata(document, issuer);
|
|
98
|
+
const identity = validateIssuerMetadata(document, issuer, discoveryUrl);
|
|
75
99
|
const methods = document.code_challenge_methods_supported;
|
|
100
|
+
const idTokenAlgs = document.id_token_signing_alg_values_supported;
|
|
76
101
|
return {
|
|
77
102
|
issuer: identity.issuer,
|
|
78
103
|
jwksUri: identity.jwksUri,
|
|
79
104
|
authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
|
|
80
|
-
tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
|
|
105
|
+
tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer, discoveryUrl),
|
|
81
106
|
codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
|
|
82
|
-
issParameterSupported: document.authorization_response_iss_parameter_supported === true
|
|
107
|
+
issParameterSupported: document.authorization_response_iss_parameter_supported === true,
|
|
108
|
+
endSessionEndpoint: optionalEndpoint(document, "end_session_endpoint", issuer),
|
|
109
|
+
idTokenSigningAlgValuesSupported: Array.isArray(idTokenAlgs) ? idTokenAlgs.filter((a) => typeof a === "string") : null
|
|
83
110
|
};
|
|
84
111
|
}
|
|
85
112
|
throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
|
|
@@ -79,11 +79,21 @@ var OAuth2RequestError = class extends Error {
|
|
|
79
79
|
code;
|
|
80
80
|
/** Description lisible fournie par le serveur, ou `null`. */
|
|
81
81
|
description;
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Statut HTTP de la réponse, ou `null` s'il n'est pas connu.
|
|
84
|
+
*
|
|
85
|
+
* Le code `error` ne suffit pas toujours à dire QUI est refusé : la RFC 6749
|
|
86
|
+
* §5.2 réserve `401` à l'échec d'authentification du client, et Keycloak
|
|
87
|
+
* répond `unauthorized_client` en `401` sur un secret faux — le même code
|
|
88
|
+
* qu'en `400` pour un flux que le client n'a pas le droit d'employer.
|
|
89
|
+
*/
|
|
90
|
+
status;
|
|
91
|
+
constructor(code, description, status = null) {
|
|
83
92
|
super(description === null ? code : `${code}: ${description}`);
|
|
84
93
|
this.name = "OAuth2RequestError";
|
|
85
94
|
this.code = code;
|
|
86
95
|
this.description = description;
|
|
96
|
+
this.status = status;
|
|
87
97
|
}
|
|
88
98
|
};
|
|
89
99
|
/**
|
|
@@ -229,38 +239,55 @@ var OAuth2Client = class {
|
|
|
229
239
|
body.set("code", request.code);
|
|
230
240
|
body.set("redirect_uri", this.#options.redirectUri);
|
|
231
241
|
if (request.codeVerifier !== null) body.set("code_verifier", request.codeVerifier);
|
|
232
|
-
|
|
233
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
234
|
-
Accept: "application/json",
|
|
235
|
-
"User-Agent": "nodefony"
|
|
236
|
-
};
|
|
237
|
-
switch (this.#options.clientAuthMethod) {
|
|
238
|
-
case "client_secret_basic":
|
|
239
|
-
headers.Authorization = `Basic ${this.#basicCredentials()}`;
|
|
240
|
-
break;
|
|
241
|
-
case "client_secret_post":
|
|
242
|
-
body.set("client_id", this.#options.clientId);
|
|
243
|
-
body.set("client_secret", this.#options.clientSecret);
|
|
244
|
-
break;
|
|
245
|
-
case "none": body.set("client_id", this.#options.clientId);
|
|
246
|
-
}
|
|
247
|
-
const response = await (this.#options.fetch ?? globalThis.fetch)(this.#options.tokenEndpoint, {
|
|
248
|
-
method: "POST",
|
|
249
|
-
headers,
|
|
250
|
-
body: body.toString(),
|
|
251
|
-
redirect: "error",
|
|
252
|
-
signal: AbortSignal.timeout(this.#options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
|
|
253
|
-
});
|
|
254
|
-
const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
|
|
255
|
-
if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null);
|
|
256
|
-
if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
|
|
257
|
-
return new OAuth2Tokens(data);
|
|
258
|
-
}
|
|
259
|
-
#basicCredentials() {
|
|
260
|
-
const id = formUrlencode(this.#options.clientId);
|
|
261
|
-
const secret = formUrlencode(this.#options.clientSecret);
|
|
262
|
-
return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
|
|
242
|
+
return postTokenRequest(this.#options, body);
|
|
263
243
|
}
|
|
264
244
|
};
|
|
245
|
+
/**
|
|
246
|
+
* Présente une requête au point de jeton et rend les jetons (RFC 6749 §5.1).
|
|
247
|
+
*
|
|
248
|
+
* SEULE implémentation de l'authentification du client et de la lecture de la
|
|
249
|
+
* réponse : l'échange de code et le flux `client_credentials` y passent tous
|
|
250
|
+
* deux, pour qu'une règle (méthode déclarée, redirection refusée, réponse
|
|
251
|
+
* bornée, refus lu sur `error`) ne diverge jamais entre deux flux.
|
|
252
|
+
*
|
|
253
|
+
* @param client - point de jeton et identité du client.
|
|
254
|
+
* @param body - corps de la requête, `grant_type` compris ; l'identité du
|
|
255
|
+
* client y est ajoutée selon la méthode déclarée.
|
|
256
|
+
* @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
|
|
257
|
+
* @throws Error - réponse inintelligible, hors gabarit, ou serveur injoignable.
|
|
258
|
+
*/
|
|
259
|
+
async function postTokenRequest(client, body) {
|
|
260
|
+
const headers = {
|
|
261
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
262
|
+
Accept: "application/json",
|
|
263
|
+
"User-Agent": "nodefony"
|
|
264
|
+
};
|
|
265
|
+
switch (client.clientAuthMethod) {
|
|
266
|
+
case "client_secret_basic":
|
|
267
|
+
headers.Authorization = `Basic ${basicCredentials(client)}`;
|
|
268
|
+
break;
|
|
269
|
+
case "client_secret_post":
|
|
270
|
+
body.set("client_id", client.clientId);
|
|
271
|
+
body.set("client_secret", client.clientSecret);
|
|
272
|
+
break;
|
|
273
|
+
case "none": body.set("client_id", client.clientId);
|
|
274
|
+
}
|
|
275
|
+
const response = await (client.fetch ?? globalThis.fetch)(client.tokenEndpoint, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers,
|
|
278
|
+
body: body.toString(),
|
|
279
|
+
redirect: "error",
|
|
280
|
+
signal: AbortSignal.timeout(client.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
|
|
281
|
+
});
|
|
282
|
+
const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
|
|
283
|
+
if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null, response.status);
|
|
284
|
+
if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
|
|
285
|
+
return new OAuth2Tokens(data);
|
|
286
|
+
}
|
|
287
|
+
function basicCredentials(client) {
|
|
288
|
+
const id = formUrlencode(client.clientId);
|
|
289
|
+
const secret = formUrlencode(client.clientSecret);
|
|
290
|
+
return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
|
|
291
|
+
}
|
|
265
292
|
//#endregion
|
|
266
|
-
export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState };
|
|
293
|
+
export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState, postTokenRequest };
|
|
@@ -5,22 +5,55 @@ const factories = /* @__PURE__ */ new Map();
|
|
|
5
5
|
/**
|
|
6
6
|
* Enregistre (ou remplace) la fabrique d'un fournisseur OAuth. Appelée par les
|
|
7
7
|
* builtins au chargement, et par une application pour ses fournisseurs.
|
|
8
|
+
*
|
|
9
|
+
* @param name - nom sous lequel le fournisseur se configure (`oauth2.providers.<name>`).
|
|
10
|
+
* @param factory - fabrique du fournisseur.
|
|
11
|
+
* @param registration - exigences de configuration, vérifiées au démarrage.
|
|
8
12
|
*/
|
|
9
|
-
function registerOAuthProvider(name, factory) {
|
|
10
|
-
factories.set(name,
|
|
13
|
+
function registerOAuthProvider(name, factory, registration = {}) {
|
|
14
|
+
factories.set(name, {
|
|
15
|
+
factory,
|
|
16
|
+
requiresIssuer: registration.requiresIssuer === true
|
|
17
|
+
});
|
|
11
18
|
}
|
|
12
19
|
/** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
|
|
13
20
|
function getOAuthProviderFactory(name) {
|
|
14
|
-
return factories.get(name);
|
|
21
|
+
return factories.get(name)?.factory;
|
|
22
|
+
}
|
|
23
|
+
/** `true` si le fournisseur nommé a déclaré exiger un émetteur en configuration. */
|
|
24
|
+
function oauthProviderRequiresIssuer(name) {
|
|
25
|
+
return factories.get(name)?.requiresIssuer === true;
|
|
15
26
|
}
|
|
16
27
|
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
17
28
|
function listOAuthProviders() {
|
|
18
29
|
return [...factories.keys()];
|
|
19
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Paramètre d'autorisation que lit le thème Keycloak livré (`template.ftl`)
|
|
33
|
+
* pour afficher l'écran dans le thème de l'application (`light` | `dark`).
|
|
34
|
+
*/
|
|
35
|
+
const KEYCLOAK_THEME_PARAMETER = "nf_theme";
|
|
36
|
+
/**
|
|
37
|
+
* Ajoute le thème demandé ({@link IAuthorizationRequest.theme}) à l'URL
|
|
38
|
+
* d'autorisation d'un fournisseur Keycloak, sous {@link KEYCLOAK_THEME_PARAMETER}.
|
|
39
|
+
*
|
|
40
|
+
* @param provider - fournisseur OIDC découvert, inchangé pour tout le reste
|
|
41
|
+
* @returns le même fournisseur, dont l'URL d'autorisation porte le thème
|
|
42
|
+
*/
|
|
43
|
+
function withKeycloakThemeHint(provider) {
|
|
44
|
+
return {
|
|
45
|
+
...provider,
|
|
46
|
+
createAuthorizationURL(request) {
|
|
47
|
+
const url = provider.createAuthorizationURL(request);
|
|
48
|
+
if (request.theme === "light" || request.theme === "dark") url.searchParams.set(KEYCLOAK_THEME_PARAMETER, request.theme);
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
20
53
|
const GOOGLE_ISSUER = "https://accounts.google.com";
|
|
21
54
|
registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
|
|
22
|
-
registerOAuthProvider("keycloak", (ctx) => createDiscoveredOidcProvider("keycloak", ctx));
|
|
23
|
-
registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx));
|
|
55
|
+
registerOAuthProvider("keycloak", async (ctx) => withKeycloakThemeHint(await createDiscoveredOidcProvider("keycloak", ctx)), { requiresIssuer: true });
|
|
56
|
+
registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx), { requiresIssuer: true });
|
|
24
57
|
registerOAuthProvider("github", createGithubProvider);
|
|
25
58
|
//#endregion
|
|
26
|
-
export { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider };
|
|
59
|
+
export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
|