alepha 0.19.4 → 0.19.5
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/api/audits/index.d.ts +8 -8
- package/dist/api/issues/index.d.ts +810 -0
- package/dist/api/issues/index.d.ts.map +1 -0
- package/dist/api/issues/index.js +447 -0
- package/dist/api/issues/index.js.map +1 -0
- package/dist/api/keys/index.d.ts +5 -5
- package/dist/api/users/index.d.ts +6 -0
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +10 -3
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/workflows/index.d.ts +3 -3
- package/dist/captcha/index.d.ts +142 -0
- package/dist/captcha/index.d.ts.map +1 -0
- package/dist/captcha/index.js +177 -0
- package/dist/captcha/index.js.map +1 -0
- package/dist/cli/core/index.d.ts +82 -2
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +90 -6
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +84 -10
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +92 -4
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +30 -3
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +98 -21
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +2 -3
- package/dist/command/index.js.map +1 -1
- package/dist/orm/core/index.bun.js +6 -6
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +6 -6
- package/dist/orm/core/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +1 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +8 -4
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +145 -2
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +364 -63
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js.map +1 -1
- package/package.json +11 -1
- package/src/api/issues/__tests__/IssueService.spec.ts +263 -0
- package/src/api/issues/controllers/AdminIssueController.ts +149 -0
- package/src/api/issues/controllers/IssueController.ts +44 -0
- package/src/api/issues/entities/issues.ts +49 -0
- package/src/api/issues/index.ts +53 -0
- package/src/api/issues/schemas/createIssueSchema.ts +13 -0
- package/src/api/issues/schemas/issueConfigAtom.ts +13 -0
- package/src/api/issues/schemas/issueQuerySchema.ts +18 -0
- package/src/api/issues/schemas/issueResourceSchema.ts +6 -0
- package/src/api/issues/schemas/myIssueQuerySchema.ts +10 -0
- package/src/api/issues/schemas/updateIssueSchema.ts +13 -0
- package/src/api/issues/services/IssueService.ts +264 -0
- package/src/api/users/primitives/$realm.ts +24 -0
- package/src/api/users/services/CredentialService.ts +6 -3
- package/src/api/users/services/RegistrationService.ts +15 -5
- package/src/api/users/services/SessionService.ts +2 -0
- package/src/captcha/__tests__/MemoryCaptchaProvider.spec.ts +74 -0
- package/src/captcha/index.ts +33 -0
- package/src/captcha/providers/CaptchaProvider.ts +17 -0
- package/src/captcha/providers/MemoryCaptchaProvider.ts +65 -0
- package/src/captcha/providers/TurnstileCaptchaProvider.ts +125 -0
- package/src/cli/core/atoms/buildOptions.ts +57 -0
- package/src/cli/core/commands/build.ts +2 -0
- package/src/cli/core/providers/ViteDevServerProvider.ts +1 -1
- package/src/cli/core/services/ViteUtils.ts +5 -2
- package/src/cli/core/tasks/BuildClientTask.ts +3 -1
- package/src/cli/core/tasks/BuildCloudflareTask.ts +4 -0
- package/src/cli/core/tasks/BuildPwaTask.ts +81 -0
- package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -0
- package/src/cli/platform/atoms/platformOptions.ts +19 -3
- package/src/cli/platform/hooks/PlatformHook.ts +51 -0
- package/src/cli/platform/index.ts +1 -0
- package/src/cli/platform/services/CloudflareApi.ts +22 -1
- package/src/cli/platform/services/PlatformOrchestrator.ts +67 -2
- package/src/cli/vendor/__tests__/VendorService.spec.ts +40 -1
- package/src/cli/vendor/commands/VendorCommand.ts +41 -38
- package/src/cli/vendor/services/VendorService.ts +108 -4
- package/src/command/__tests__/CliProvider.spec.ts +45 -0
- package/src/command/providers/CliProvider.ts +3 -4
- package/src/orm/core/services/Repository.ts +20 -6
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +83 -0
- package/src/react/i18n/providers/I18nProvider.ts +12 -10
- package/src/security/primitives/$issuer.ts +3 -1
- package/src/server/auth/index.ts +7 -0
- package/src/server/auth/primitives/$auth.ts +37 -3
- package/src/server/auth/primitives/$authApple.ts +114 -4
- package/src/server/auth/primitives/$authFacebook.ts +98 -0
- package/src/server/auth/primitives/$authFranceConnect.ts +105 -0
- package/src/server/auth/primitives/$authGithub.ts +22 -16
- package/src/server/auth/primitives/$authMicrosoft.ts +88 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +197 -72
- package/src/server/cookies/providers/ServerCookiesProvider.ts +3 -0
- package/src/server/core/__tests__/ServerRouterProvider-errorHandler.spec.ts +1 -1
- package/src/websocket/providers/NodeWebSocketServerProvider.ts +3 -1
|
@@ -42,7 +42,6 @@ export class I18nProvider<
|
|
|
42
42
|
for (const item of this.registry) {
|
|
43
43
|
languages.add(item.lang);
|
|
44
44
|
}
|
|
45
|
-
languages.add(this.options.fallbackLang);
|
|
46
45
|
|
|
47
46
|
return Array.from(languages);
|
|
48
47
|
}
|
|
@@ -70,10 +69,7 @@ export class I18nProvider<
|
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
for (const item of this.registry) {
|
|
73
|
-
if (
|
|
74
|
-
item.lang === this.lang ||
|
|
75
|
-
item.lang === this.options.fallbackLang
|
|
76
|
-
) {
|
|
72
|
+
if (item.lang === this.lang || item.lang === this.fallbackLang) {
|
|
77
73
|
this.log.trace("Loading language", {
|
|
78
74
|
lang: item.lang,
|
|
79
75
|
name: item.name,
|
|
@@ -139,11 +135,17 @@ export class I18nProvider<
|
|
|
139
135
|
},
|
|
140
136
|
});
|
|
141
137
|
|
|
138
|
+
public get fallbackLang(): string {
|
|
139
|
+
const configured = this.options.fallbackLang;
|
|
140
|
+
const hasDict = this.registry.some((item) => item.lang === configured);
|
|
141
|
+
if (hasDict) {
|
|
142
|
+
return configured;
|
|
143
|
+
}
|
|
144
|
+
return this.registry[0]?.lang ?? configured;
|
|
145
|
+
}
|
|
146
|
+
|
|
142
147
|
public get lang(): string {
|
|
143
|
-
return (
|
|
144
|
-
this.alepha.store.get("alepha.react.i18n.lang") ||
|
|
145
|
-
this.options.fallbackLang
|
|
146
|
-
);
|
|
148
|
+
return this.alepha.store.get("alepha.react.i18n.lang") || this.fallbackLang;
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
public translate = (key: string, args: string[] = []) => {
|
|
@@ -156,7 +158,7 @@ export class I18nProvider<
|
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
for (const item of this.registry) {
|
|
159
|
-
if (item.lang === this.
|
|
161
|
+
if (item.lang === this.fallbackLang) {
|
|
160
162
|
if (item.translations[key]) {
|
|
161
163
|
return this.render(item.translations[key], args); // append lang for fallback
|
|
162
164
|
}
|
|
@@ -75,7 +75,9 @@ export interface IssuerSettings {
|
|
|
75
75
|
*/
|
|
76
76
|
expiration?: DurationLike;
|
|
77
77
|
|
|
78
|
-
// TODO: expirationIdle (max inactive time before the token is invalidated)
|
|
78
|
+
// TODO: expirationIdle (max inactive time before the token is invalidated).
|
|
79
|
+
// Requires tracking lastUsedAt on session refresh and rejecting tokens
|
|
80
|
+
// that have been idle longer than the threshold.
|
|
79
81
|
};
|
|
80
82
|
|
|
81
83
|
onCreateSession?: (
|
package/src/server/auth/index.ts
CHANGED
|
@@ -7,9 +7,13 @@ import { ServerAuthProvider } from "./providers/ServerAuthProvider.ts";
|
|
|
7
7
|
|
|
8
8
|
export * from "./index.shared.ts";
|
|
9
9
|
export * from "./primitives/$auth.ts";
|
|
10
|
+
export * from "./primitives/$authApple.ts";
|
|
10
11
|
export * from "./primitives/$authCredentials.ts";
|
|
12
|
+
export * from "./primitives/$authFacebook.ts";
|
|
13
|
+
export * from "./primitives/$authFranceConnect.ts";
|
|
11
14
|
export * from "./primitives/$authGithub.ts";
|
|
12
15
|
export * from "./primitives/$authGoogle.ts";
|
|
16
|
+
export * from "./primitives/$authMicrosoft.ts";
|
|
13
17
|
export * from "./providers/ServerAuthProvider.ts";
|
|
14
18
|
|
|
15
19
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -23,6 +27,9 @@ export * from "./providers/ServerAuthProvider.ts";
|
|
|
23
27
|
* - Google OAuth integration
|
|
24
28
|
* - GitHub OAuth integration
|
|
25
29
|
* - Apple OAuth integration
|
|
30
|
+
* - Facebook OAuth integration
|
|
31
|
+
* - Microsoft Entra ID (Azure AD) integration
|
|
32
|
+
* - France Connect integration
|
|
26
33
|
* - Cookie-based, SSR-friendly authentication
|
|
27
34
|
* - Token management and refresh
|
|
28
35
|
*
|
|
@@ -198,6 +198,19 @@ export interface OidcOptions {
|
|
|
198
198
|
scope?: string;
|
|
199
199
|
|
|
200
200
|
account?: LinkAccountFn;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* OAuth2 response mode.
|
|
204
|
+
* Apple requires "form_post" which sends the authorization code via POST body
|
|
205
|
+
* instead of URL query parameters.
|
|
206
|
+
*/
|
|
207
|
+
responseMode?: "query" | "fragment" | "form_post";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Additional parameters to include in the authorization URL.
|
|
211
|
+
* Useful for provider-specific parameters.
|
|
212
|
+
*/
|
|
213
|
+
authorizationParameters?: Record<string, string>;
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
export interface LinkAccountOptions {
|
|
@@ -369,11 +382,22 @@ export class AuthPrimitive extends Primitive<AuthPrimitiveOptions> {
|
|
|
369
382
|
/**
|
|
370
383
|
* Extracts user information from the access token.
|
|
371
384
|
* This is used to create a user account from the access token.
|
|
385
|
+
*
|
|
386
|
+
* `externalProfile` carries extra profile fields that cannot be derived from the
|
|
387
|
+
* ID token or userinfo endpoint — e.g. Apple's `user` form field that is only
|
|
388
|
+
* delivered once, on first authorization. ID token / userinfo fields take
|
|
389
|
+
* precedence; externalProfile only fills gaps.
|
|
372
390
|
*/
|
|
373
|
-
public async user(
|
|
391
|
+
public async user(
|
|
392
|
+
tokens: Tokens,
|
|
393
|
+
externalProfile?: Record<string, unknown>,
|
|
394
|
+
): Promise<UserAccount> {
|
|
374
395
|
try {
|
|
375
396
|
if ("oauth" in this.options) {
|
|
376
|
-
const profile =
|
|
397
|
+
const profile = {
|
|
398
|
+
...externalProfile,
|
|
399
|
+
...(await this.options.oauth.userinfo(tokens)),
|
|
400
|
+
} as OAuth2Profile;
|
|
377
401
|
|
|
378
402
|
if (this.options.oauth.account) {
|
|
379
403
|
return this.options.oauth.account({
|
|
@@ -386,7 +410,10 @@ export class AuthPrimitive extends Primitive<AuthPrimitiveOptions> {
|
|
|
386
410
|
}
|
|
387
411
|
|
|
388
412
|
if ("oidc" in this.options) {
|
|
389
|
-
const payload =
|
|
413
|
+
const payload = {
|
|
414
|
+
...externalProfile,
|
|
415
|
+
...this.getUserFromIdToken(tokens.id_token || ""),
|
|
416
|
+
} as OAuth2Profile;
|
|
390
417
|
|
|
391
418
|
if (this.options.oidc.account) {
|
|
392
419
|
return this.options.oidc.account({
|
|
@@ -411,6 +438,13 @@ export class AuthPrimitive extends Primitive<AuthPrimitiveOptions> {
|
|
|
411
438
|
);
|
|
412
439
|
}
|
|
413
440
|
|
|
441
|
+
// Security note: No JWT signature verification here is intentional and safe.
|
|
442
|
+
// The id_token is received via authorizationCodeGrant() which fetches it over a
|
|
443
|
+
// back-channel TLS connection directly from the IdP's token endpoint. TLS authenticates
|
|
444
|
+
// the channel. openid-client/oauth4webapi validates claims (issuer, audience, nonce,
|
|
445
|
+
// expiry) during the grant. Per OIDC spec, cryptographic signature verification is
|
|
446
|
+
// not required for back-channel token responses — only for implicit/hybrid flows.
|
|
447
|
+
// See: openid-client index.d.ts enableNonRepudiationChecks() docs.
|
|
414
448
|
protected getUserFromIdToken(idToken: string): OAuth2Profile {
|
|
415
449
|
try {
|
|
416
450
|
return JSON.parse(
|
|
@@ -1,8 +1,118 @@
|
|
|
1
|
-
import { AlephaError } from "alepha";
|
|
1
|
+
import { $context, AlephaError, t } from "alepha";
|
|
2
|
+
import type { IssuerPrimitive } from "alepha/security";
|
|
3
|
+
import type { OAuth2Profile } from "../providers/ServerAuthProvider.ts";
|
|
4
|
+
import {
|
|
5
|
+
$auth,
|
|
6
|
+
type LinkAccountFn,
|
|
7
|
+
type LinkAccountOptions,
|
|
8
|
+
type OidcOptions,
|
|
9
|
+
type WithLinkFn,
|
|
10
|
+
} from "./$auth.ts";
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
|
-
*
|
|
13
|
+
* Already configured Apple authentication primitive.
|
|
14
|
+
*
|
|
15
|
+
* Uses OpenID Connect (OIDC) to authenticate users via their Apple accounts.
|
|
16
|
+
* Upon successful authentication, it links the Apple account to a user session.
|
|
17
|
+
*
|
|
18
|
+
* Apple-specific behavior:
|
|
19
|
+
* - `response_mode=form_post` (required by Apple when requesting `email`/`name`).
|
|
20
|
+
* - Scope: `name email` (Apple does not support the standard `profile` scope).
|
|
21
|
+
* - The user's name is only provided on the first authorization, as a `user`
|
|
22
|
+
* form field on the POST callback. The framework extracts it and injects
|
|
23
|
+
* `given_name` / `family_name` / `name` into the profile before linking.
|
|
24
|
+
* Subsequent logins only return `sub` and `email` in the ID token.
|
|
25
|
+
* - `email_verified` and `is_private_email` are normalized from Apple's
|
|
26
|
+
* string ("true"/"false") representation to booleans.
|
|
27
|
+
*
|
|
28
|
+
* Client secret:
|
|
29
|
+
* Apple requires the client secret to be a signed ES256 JWT generated from
|
|
30
|
+
* your Apple private key, team ID, and key ID. This JWT is valid for up to 6
|
|
31
|
+
* months; you must rotate it before expiration. Generate it out of band and
|
|
32
|
+
* set it via `APPLE_CLIENT_SECRET`.
|
|
33
|
+
*
|
|
34
|
+
* See: https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
|
|
35
|
+
*
|
|
36
|
+
* Environment Variables:
|
|
37
|
+
* - `APPLE_CLIENT_ID`: The Service ID obtained from the Apple Developer Console.
|
|
38
|
+
* - `APPLE_CLIENT_SECRET`: The signed ES256 JWT client secret generated from your
|
|
39
|
+
* Apple private key.
|
|
5
40
|
*/
|
|
6
|
-
export const $authApple = (
|
|
7
|
-
|
|
41
|
+
export const $authApple = (
|
|
42
|
+
realm: IssuerPrimitive & WithLinkFn,
|
|
43
|
+
options: Partial<OidcOptions> = {},
|
|
44
|
+
) => {
|
|
45
|
+
const { alepha } = $context();
|
|
46
|
+
|
|
47
|
+
const env = alepha.parseEnv(
|
|
48
|
+
t.object({
|
|
49
|
+
APPLE_CLIENT_ID: t.optional(
|
|
50
|
+
t.text({
|
|
51
|
+
description:
|
|
52
|
+
"The Service ID obtained from the Apple Developer Console.",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
APPLE_CLIENT_SECRET: t.optional(
|
|
56
|
+
t.text({
|
|
57
|
+
description:
|
|
58
|
+
"The signed ES256 JWT client secret generated from your Apple private key.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const disabled = !env.APPLE_CLIENT_ID || !env.APPLE_CLIENT_SECRET;
|
|
65
|
+
|
|
66
|
+
const name = "apple";
|
|
67
|
+
|
|
68
|
+
const userAccount: LinkAccountFn | undefined =
|
|
69
|
+
options.account ?? (realm.link ? realm.link(name) : undefined);
|
|
70
|
+
|
|
71
|
+
if (!userAccount) {
|
|
72
|
+
throw new AlephaError(
|
|
73
|
+
"Authentication requires a link function in the realm primitive.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const account: LinkAccountFn = async (opts) => {
|
|
78
|
+
return userAccount(normalizeApplePayload(opts));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return $auth({
|
|
82
|
+
issuer: realm,
|
|
83
|
+
name,
|
|
84
|
+
oidc: {
|
|
85
|
+
issuer: "https://appleid.apple.com",
|
|
86
|
+
clientId: env.APPLE_CLIENT_ID!,
|
|
87
|
+
clientSecret: env.APPLE_CLIENT_SECRET,
|
|
88
|
+
scope: "name email",
|
|
89
|
+
responseMode: "form_post",
|
|
90
|
+
...options,
|
|
91
|
+
account,
|
|
92
|
+
},
|
|
93
|
+
disabled,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalize Apple-specific profile quirks before handing off to the
|
|
99
|
+
* user-provided link function.
|
|
100
|
+
*
|
|
101
|
+
* Why: Apple's ID token non-conformities — `email_verified` and
|
|
102
|
+
* `is_private_email` are delivered as the strings "true"/"false" rather than
|
|
103
|
+
* booleans. Normalize so downstream code can rely on standard OIDC shapes.
|
|
104
|
+
*/
|
|
105
|
+
const normalizeApplePayload = (
|
|
106
|
+
opts: LinkAccountOptions,
|
|
107
|
+
): LinkAccountOptions => {
|
|
108
|
+
const user: OAuth2Profile = { ...opts.user };
|
|
109
|
+
|
|
110
|
+
for (const key of ["email_verified", "is_private_email"] as const) {
|
|
111
|
+
const raw = user[key] as unknown;
|
|
112
|
+
if (typeof raw === "string") {
|
|
113
|
+
user[key] = raw === "true";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { ...opts, user };
|
|
8
118
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { $context, AlephaError, t } from "alepha";
|
|
2
|
+
import type { IssuerPrimitive } from "alepha/security";
|
|
3
|
+
import type { OAuth2Profile } from "../providers/ServerAuthProvider.ts";
|
|
4
|
+
import {
|
|
5
|
+
$auth,
|
|
6
|
+
type LinkAccountFn,
|
|
7
|
+
type OidcOptions,
|
|
8
|
+
type WithLinkFn,
|
|
9
|
+
} from "./$auth.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Already configured Facebook authentication primitive.
|
|
13
|
+
*
|
|
14
|
+
* Uses OAuth2 to authenticate users via their Facebook accounts.
|
|
15
|
+
* Upon successful authentication, it links the Facebook account to a user session.
|
|
16
|
+
*
|
|
17
|
+
* Environment Variables:
|
|
18
|
+
* - `FACEBOOK_CLIENT_ID`: The App ID obtained from the Meta Developer Console.
|
|
19
|
+
* - `FACEBOOK_CLIENT_SECRET`: The App Secret obtained from the Meta Developer Console.
|
|
20
|
+
*/
|
|
21
|
+
export const $authFacebook = (
|
|
22
|
+
realm: IssuerPrimitive & WithLinkFn,
|
|
23
|
+
options: Partial<OidcOptions> = {},
|
|
24
|
+
) => {
|
|
25
|
+
const { alepha } = $context();
|
|
26
|
+
|
|
27
|
+
const env = alepha.parseEnv(
|
|
28
|
+
t.object({
|
|
29
|
+
FACEBOOK_CLIENT_ID: t.optional(
|
|
30
|
+
t.text({
|
|
31
|
+
description: "The App ID obtained from the Meta Developer Console.",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
FACEBOOK_CLIENT_SECRET: t.optional(
|
|
35
|
+
t.text({
|
|
36
|
+
description:
|
|
37
|
+
"The App Secret obtained from the Meta Developer Console.",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const disabled = !env.FACEBOOK_CLIENT_ID || !env.FACEBOOK_CLIENT_SECRET;
|
|
44
|
+
|
|
45
|
+
const name = "facebook";
|
|
46
|
+
|
|
47
|
+
const account: LinkAccountFn | undefined =
|
|
48
|
+
options.account ?? (realm.link ? realm.link(name) : undefined);
|
|
49
|
+
|
|
50
|
+
if (!account) {
|
|
51
|
+
throw new AlephaError(
|
|
52
|
+
"Authentication requires a link function in the realm primitive.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return $auth({
|
|
57
|
+
issuer: realm,
|
|
58
|
+
name,
|
|
59
|
+
oauth: {
|
|
60
|
+
clientId: env.FACEBOOK_CLIENT_ID!,
|
|
61
|
+
clientSecret: env.FACEBOOK_CLIENT_SECRET!,
|
|
62
|
+
authorization: "https://www.facebook.com/v25.0/dialog/oauth",
|
|
63
|
+
token: "https://graph.facebook.com/v25.0/oauth/access_token",
|
|
64
|
+
scope: "email",
|
|
65
|
+
userinfo: async (tokens) => {
|
|
66
|
+
const res = await fetch(
|
|
67
|
+
"https://graph.facebook.com/v25.0/me?fields=id,name,email,picture.width(200).height(200)",
|
|
68
|
+
{
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
).then((res) => res.json());
|
|
74
|
+
|
|
75
|
+
const user: OAuth2Profile = {
|
|
76
|
+
sub: res.id,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (res.email) {
|
|
80
|
+
user.email = res.email;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (res.name) {
|
|
84
|
+
user.name = res.name.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (res.picture?.data?.url) {
|
|
88
|
+
user.picture = res.picture.data.url;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return user;
|
|
92
|
+
},
|
|
93
|
+
...options,
|
|
94
|
+
account,
|
|
95
|
+
},
|
|
96
|
+
disabled,
|
|
97
|
+
});
|
|
98
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { $context, AlephaError, t } from "alepha";
|
|
2
|
+
import type { IssuerPrimitive } from "alepha/security";
|
|
3
|
+
import {
|
|
4
|
+
$auth,
|
|
5
|
+
type LinkAccountFn,
|
|
6
|
+
type OidcOptions,
|
|
7
|
+
type WithLinkFn,
|
|
8
|
+
} from "./$auth.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates an authentication provider primitive for France Connect.
|
|
12
|
+
*
|
|
13
|
+
* Uses OpenID Connect (OIDC) to authenticate users via France Connect,
|
|
14
|
+
* the French government's identity federation system. It provides verified
|
|
15
|
+
* identity data (name, email, birthdate) sourced directly from government
|
|
16
|
+
* databases.
|
|
17
|
+
*
|
|
18
|
+
* **France Connect-specific behaviour**:
|
|
19
|
+
* - Scopes use individual claim names (`given_name`, `family_name`) rather
|
|
20
|
+
* than the standard grouped `profile` scope.
|
|
21
|
+
* - The `acr_values=eidas1` authorization parameter is mandatory and is
|
|
22
|
+
* included automatically.
|
|
23
|
+
* - Logout is mandatory in France Connect integrations. Store the `id_token`
|
|
24
|
+
* returned at login and pass it to the logout endpoint when the session ends.
|
|
25
|
+
*
|
|
26
|
+
* **Environment Variables** (obtain from partenaires.franceconnect.gouv.fr):
|
|
27
|
+
* - `FRANCECONNECT_CLIENT_ID` — OAuth 2.0 client ID for your France Connect service provider.
|
|
28
|
+
* - `FRANCECONNECT_CLIENT_SECRET` — OAuth 2.0 client secret for your France Connect service provider.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* class AuthProviders {
|
|
33
|
+
* franceconnect = $authFranceConnect(this.userRealm);
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export const $authFranceConnect = (
|
|
38
|
+
realm: IssuerPrimitive & WithLinkFn,
|
|
39
|
+
options: Partial<OidcOptions> = {},
|
|
40
|
+
) => {
|
|
41
|
+
const { alepha } = $context();
|
|
42
|
+
|
|
43
|
+
const env = alepha.parseEnv(
|
|
44
|
+
t.object({
|
|
45
|
+
FRANCECONNECT_CLIENT_ID: t.optional(
|
|
46
|
+
t.text({
|
|
47
|
+
description:
|
|
48
|
+
"The OAuth 2.0 client ID for your France Connect service provider, obtained from partenaires.franceconnect.gouv.fr.",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
FRANCECONNECT_CLIENT_SECRET: t.optional(
|
|
52
|
+
t.text({
|
|
53
|
+
description:
|
|
54
|
+
"The OAuth 2.0 client secret for your France Connect service provider, obtained from partenaires.franceconnect.gouv.fr.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const disabled =
|
|
61
|
+
!env.FRANCECONNECT_CLIENT_ID || !env.FRANCECONNECT_CLIENT_SECRET;
|
|
62
|
+
|
|
63
|
+
const name = "franceconnect";
|
|
64
|
+
|
|
65
|
+
const account: LinkAccountFn | undefined =
|
|
66
|
+
options.account ?? (realm.link ? realm.link(name) : undefined);
|
|
67
|
+
|
|
68
|
+
if (!account) {
|
|
69
|
+
throw new AlephaError(
|
|
70
|
+
"Authentication requires a link function in the realm primitive.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return $auth({
|
|
75
|
+
issuer: realm,
|
|
76
|
+
name,
|
|
77
|
+
oidc: {
|
|
78
|
+
/**
|
|
79
|
+
* France Connect production OIDC issuer.
|
|
80
|
+
* Discovery: https://oidc.franceconnect.gouv.fr/api/v2/.well-known/openid-configuration
|
|
81
|
+
*
|
|
82
|
+
* Note: `oidc.franceconnect.gouv.fr` is standard FranceConnect (eidas1).
|
|
83
|
+
* `auth.franceconnect.gouv.fr` is FranceConnect+ (eidas2/eidas3).
|
|
84
|
+
*/
|
|
85
|
+
issuer: "https://oidc.franceconnect.gouv.fr/api/v2",
|
|
86
|
+
clientId: env.FRANCECONNECT_CLIENT_ID!,
|
|
87
|
+
clientSecret: env.FRANCECONNECT_CLIENT_SECRET,
|
|
88
|
+
/**
|
|
89
|
+
* France Connect requires individual claim names as scopes.
|
|
90
|
+
* The standard grouped `profile` scope is NOT supported.
|
|
91
|
+
*/
|
|
92
|
+
scope: "openid given_name family_name email",
|
|
93
|
+
/**
|
|
94
|
+
* `acr_values=eidas1` is mandatory for all France Connect integrations.
|
|
95
|
+
*/
|
|
96
|
+
...options,
|
|
97
|
+
authorizationParameters: {
|
|
98
|
+
acr_values: "eidas1",
|
|
99
|
+
...options.authorizationParameters,
|
|
100
|
+
},
|
|
101
|
+
account,
|
|
102
|
+
},
|
|
103
|
+
disabled,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
@@ -65,12 +65,13 @@ export const $authGithub = (
|
|
|
65
65
|
scope: "read:user user:email",
|
|
66
66
|
userinfo: async (tokens) => {
|
|
67
67
|
const BASE_URL = "https://api.github.com";
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const headers = {
|
|
69
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
70
|
+
"User-Agent": "Alepha",
|
|
71
|
+
};
|
|
72
|
+
const res = await fetch(`${BASE_URL}/user`, { headers }).then((res) =>
|
|
73
|
+
res.json(),
|
|
74
|
+
);
|
|
74
75
|
|
|
75
76
|
const user: OAuth2Profile = {
|
|
76
77
|
sub: res.id.toString(),
|
|
@@ -88,16 +89,21 @@ export const $authGithub = (
|
|
|
88
89
|
user.picture = res.avatar_url;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
// `/user` omits the email if the user's public profile hides it, and
|
|
93
|
+
// never exposes `verified`. Fetch `/user/emails` to fill in both.
|
|
94
|
+
const emailsRes = await fetch(`${BASE_URL}/user/emails`, { headers });
|
|
95
|
+
if (emailsRes.ok) {
|
|
96
|
+
const emails: Array<{
|
|
97
|
+
email: string;
|
|
98
|
+
primary: boolean;
|
|
99
|
+
verified: boolean;
|
|
100
|
+
}> = await emailsRes.json();
|
|
101
|
+
if (!user.email) {
|
|
102
|
+
user.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
103
|
+
}
|
|
104
|
+
if (user.email) {
|
|
105
|
+
user.email_verified =
|
|
106
|
+
emails.find((e) => e.email === user.email)?.verified ?? false;
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { $context, AlephaError, t } from "alepha";
|
|
2
|
+
import type { IssuerPrimitive } from "alepha/security";
|
|
3
|
+
import {
|
|
4
|
+
$auth,
|
|
5
|
+
type LinkAccountFn,
|
|
6
|
+
type OidcOptions,
|
|
7
|
+
type WithLinkFn,
|
|
8
|
+
} from "./$auth.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Already configured Microsoft Entra ID (Azure AD) authentication primitive.
|
|
12
|
+
*
|
|
13
|
+
* Uses OpenID Connect (OIDC) to authenticate users via their Microsoft accounts.
|
|
14
|
+
* Supports personal Microsoft accounts, work/school (Azure AD) accounts, and
|
|
15
|
+
* multi-tenant applications.
|
|
16
|
+
*
|
|
17
|
+
* The tenant ID defaults to `"common"`, which allows all Microsoft account types
|
|
18
|
+
* (personal, work, school). To restrict to a specific Azure AD tenant, set
|
|
19
|
+
* `MICROSOFT_TENANT_ID` to your tenant's GUID or domain.
|
|
20
|
+
*
|
|
21
|
+
* **Note on multi-tenant issuer validation**: Microsoft's OIDC discovery document
|
|
22
|
+
* for the `common` endpoint returns `{tenantid}` as a literal placeholder in the
|
|
23
|
+
* `issuer` field. This is expected behavior for multi-tenant endpoints. The
|
|
24
|
+
* openid-client library handles this during token validation automatically.
|
|
25
|
+
*
|
|
26
|
+
* Environment Variables:
|
|
27
|
+
* - `MICROSOFT_CLIENT_ID`: The application (client) ID from the Azure Portal.
|
|
28
|
+
* - `MICROSOFT_CLIENT_SECRET`: The client secret value from the Azure Portal.
|
|
29
|
+
* - `MICROSOFT_TENANT_ID`: (Optional) Azure AD tenant ID or `"common"` for
|
|
30
|
+
* multi-tenant. Defaults to `"common"`.
|
|
31
|
+
*/
|
|
32
|
+
export const $authMicrosoft = (
|
|
33
|
+
realm: IssuerPrimitive & WithLinkFn,
|
|
34
|
+
options: Partial<OidcOptions> = {},
|
|
35
|
+
) => {
|
|
36
|
+
const { alepha } = $context();
|
|
37
|
+
|
|
38
|
+
const env = alepha.parseEnv(
|
|
39
|
+
t.object({
|
|
40
|
+
MICROSOFT_CLIENT_ID: t.optional(
|
|
41
|
+
t.text({
|
|
42
|
+
description:
|
|
43
|
+
"The application (client) ID obtained from the Azure Portal.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
MICROSOFT_CLIENT_SECRET: t.optional(
|
|
47
|
+
t.text({
|
|
48
|
+
description:
|
|
49
|
+
"The client secret value obtained from the Azure Portal.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
MICROSOFT_TENANT_ID: t.optional(
|
|
53
|
+
t.text({
|
|
54
|
+
description:
|
|
55
|
+
"The Azure AD tenant ID or 'common' for multi-tenant. Defaults to 'common'.",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const disabled = !env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET;
|
|
62
|
+
|
|
63
|
+
const tenantId = env.MICROSOFT_TENANT_ID ?? "common";
|
|
64
|
+
|
|
65
|
+
const name = "microsoft";
|
|
66
|
+
|
|
67
|
+
const account: LinkAccountFn | undefined =
|
|
68
|
+
options.account ?? (realm.link ? realm.link(name) : undefined);
|
|
69
|
+
|
|
70
|
+
if (!account) {
|
|
71
|
+
throw new AlephaError(
|
|
72
|
+
"Authentication requires a link function in the realm primitive.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return $auth({
|
|
77
|
+
issuer: realm,
|
|
78
|
+
name,
|
|
79
|
+
oidc: {
|
|
80
|
+
issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
|
|
81
|
+
clientId: env.MICROSOFT_CLIENT_ID!,
|
|
82
|
+
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
|
83
|
+
...options,
|
|
84
|
+
account,
|
|
85
|
+
},
|
|
86
|
+
disabled,
|
|
87
|
+
});
|
|
88
|
+
};
|