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.
Files changed (104) hide show
  1. package/dist/api/audits/index.d.ts +8 -8
  2. package/dist/api/issues/index.d.ts +810 -0
  3. package/dist/api/issues/index.d.ts.map +1 -0
  4. package/dist/api/issues/index.js +447 -0
  5. package/dist/api/issues/index.js.map +1 -0
  6. package/dist/api/keys/index.d.ts +5 -5
  7. package/dist/api/users/index.d.ts +6 -0
  8. package/dist/api/users/index.d.ts.map +1 -1
  9. package/dist/api/users/index.js +10 -3
  10. package/dist/api/users/index.js.map +1 -1
  11. package/dist/api/workflows/index.d.ts +3 -3
  12. package/dist/captcha/index.d.ts +142 -0
  13. package/dist/captcha/index.d.ts.map +1 -0
  14. package/dist/captcha/index.js +177 -0
  15. package/dist/captcha/index.js.map +1 -0
  16. package/dist/cli/core/index.d.ts +82 -2
  17. package/dist/cli/core/index.d.ts.map +1 -1
  18. package/dist/cli/core/index.js +90 -6
  19. package/dist/cli/core/index.js.map +1 -1
  20. package/dist/cli/platform/index.d.ts +84 -10
  21. package/dist/cli/platform/index.d.ts.map +1 -1
  22. package/dist/cli/platform/index.js +92 -4
  23. package/dist/cli/platform/index.js.map +1 -1
  24. package/dist/cli/vendor/index.d.ts +30 -3
  25. package/dist/cli/vendor/index.d.ts.map +1 -1
  26. package/dist/cli/vendor/index.js +98 -21
  27. package/dist/cli/vendor/index.js.map +1 -1
  28. package/dist/command/index.d.ts.map +1 -1
  29. package/dist/command/index.js +2 -3
  30. package/dist/command/index.js.map +1 -1
  31. package/dist/orm/core/index.bun.js +6 -6
  32. package/dist/orm/core/index.bun.js.map +1 -1
  33. package/dist/orm/core/index.d.ts.map +1 -1
  34. package/dist/orm/core/index.js +6 -6
  35. package/dist/orm/core/index.js.map +1 -1
  36. package/dist/react/i18n/index.d.ts +1 -0
  37. package/dist/react/i18n/index.d.ts.map +1 -1
  38. package/dist/react/i18n/index.js +8 -4
  39. package/dist/react/i18n/index.js.map +1 -1
  40. package/dist/security/index.d.ts.map +1 -1
  41. package/dist/security/index.js.map +1 -1
  42. package/dist/server/auth/index.d.ts +145 -2
  43. package/dist/server/auth/index.d.ts.map +1 -1
  44. package/dist/server/auth/index.js +364 -63
  45. package/dist/server/auth/index.js.map +1 -1
  46. package/dist/server/cookies/index.d.ts.map +1 -1
  47. package/dist/server/cookies/index.js.map +1 -1
  48. package/dist/websocket/index.d.ts.map +1 -1
  49. package/dist/websocket/index.js.map +1 -1
  50. package/package.json +11 -1
  51. package/src/api/issues/__tests__/IssueService.spec.ts +263 -0
  52. package/src/api/issues/controllers/AdminIssueController.ts +149 -0
  53. package/src/api/issues/controllers/IssueController.ts +44 -0
  54. package/src/api/issues/entities/issues.ts +49 -0
  55. package/src/api/issues/index.ts +53 -0
  56. package/src/api/issues/schemas/createIssueSchema.ts +13 -0
  57. package/src/api/issues/schemas/issueConfigAtom.ts +13 -0
  58. package/src/api/issues/schemas/issueQuerySchema.ts +18 -0
  59. package/src/api/issues/schemas/issueResourceSchema.ts +6 -0
  60. package/src/api/issues/schemas/myIssueQuerySchema.ts +10 -0
  61. package/src/api/issues/schemas/updateIssueSchema.ts +13 -0
  62. package/src/api/issues/services/IssueService.ts +264 -0
  63. package/src/api/users/primitives/$realm.ts +24 -0
  64. package/src/api/users/services/CredentialService.ts +6 -3
  65. package/src/api/users/services/RegistrationService.ts +15 -5
  66. package/src/api/users/services/SessionService.ts +2 -0
  67. package/src/captcha/__tests__/MemoryCaptchaProvider.spec.ts +74 -0
  68. package/src/captcha/index.ts +33 -0
  69. package/src/captcha/providers/CaptchaProvider.ts +17 -0
  70. package/src/captcha/providers/MemoryCaptchaProvider.ts +65 -0
  71. package/src/captcha/providers/TurnstileCaptchaProvider.ts +125 -0
  72. package/src/cli/core/atoms/buildOptions.ts +57 -0
  73. package/src/cli/core/commands/build.ts +2 -0
  74. package/src/cli/core/providers/ViteDevServerProvider.ts +1 -1
  75. package/src/cli/core/services/ViteUtils.ts +5 -2
  76. package/src/cli/core/tasks/BuildClientTask.ts +3 -1
  77. package/src/cli/core/tasks/BuildCloudflareTask.ts +4 -0
  78. package/src/cli/core/tasks/BuildPwaTask.ts +81 -0
  79. package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -0
  80. package/src/cli/platform/atoms/platformOptions.ts +19 -3
  81. package/src/cli/platform/hooks/PlatformHook.ts +51 -0
  82. package/src/cli/platform/index.ts +1 -0
  83. package/src/cli/platform/services/CloudflareApi.ts +22 -1
  84. package/src/cli/platform/services/PlatformOrchestrator.ts +67 -2
  85. package/src/cli/vendor/__tests__/VendorService.spec.ts +40 -1
  86. package/src/cli/vendor/commands/VendorCommand.ts +41 -38
  87. package/src/cli/vendor/services/VendorService.ts +108 -4
  88. package/src/command/__tests__/CliProvider.spec.ts +45 -0
  89. package/src/command/providers/CliProvider.ts +3 -4
  90. package/src/orm/core/services/Repository.ts +20 -6
  91. package/src/react/i18n/__tests__/I18nProvider.spec.ts +83 -0
  92. package/src/react/i18n/providers/I18nProvider.ts +12 -10
  93. package/src/security/primitives/$issuer.ts +3 -1
  94. package/src/server/auth/index.ts +7 -0
  95. package/src/server/auth/primitives/$auth.ts +37 -3
  96. package/src/server/auth/primitives/$authApple.ts +114 -4
  97. package/src/server/auth/primitives/$authFacebook.ts +98 -0
  98. package/src/server/auth/primitives/$authFranceConnect.ts +105 -0
  99. package/src/server/auth/primitives/$authGithub.ts +22 -16
  100. package/src/server/auth/primitives/$authMicrosoft.ts +88 -0
  101. package/src/server/auth/providers/ServerAuthProvider.ts +197 -72
  102. package/src/server/cookies/providers/ServerCookiesProvider.ts +3 -0
  103. package/src/server/core/__tests__/ServerRouterProvider-errorHandler.spec.ts +1 -1
  104. 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.options.fallbackLang) {
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?: (
@@ -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(tokens: Tokens): Promise<UserAccount> {
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 = await this.options.oauth.userinfo(tokens);
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 = this.getUserFromIdToken(tokens.id_token || "");
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
- * TODO: Implement Apple authentication
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
- throw new AlephaError("Apple authentication is not implemented yet.");
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 res = await fetch(`${BASE_URL}/user`, {
69
- headers: {
70
- Authorization: `Bearer ${tokens.access_token}`,
71
- "User-Agent": "Alepha",
72
- },
73
- }).then((res) => res.json());
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
- if (!user.email) {
92
- const res = await fetch(`${BASE_URL}/user/emails`, {
93
- headers: {
94
- Authorization: `Bearer ${tokens.access_token}`,
95
- "User-Agent": "Alepha",
96
- },
97
- });
98
- if (res.ok) {
99
- const emails: any[] = await res.json();
100
- user.email = (emails.find((e) => e.primary) ?? emails[0]).email;
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
+ };