@warlock.js/auth 5.15.0 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/esm/commands/auth-cleanup-command.mjs +1 -1
  3. package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
  4. package/esm/contracts/index.d.mts +1 -1
  5. package/esm/contracts/providers.d.mts +59 -1
  6. package/esm/contracts/providers.d.mts.map +1 -1
  7. package/esm/index.d.mts +11 -2
  8. package/esm/index.mjs +11 -1
  9. package/esm/localization/ar.translations.mjs +16 -0
  10. package/esm/localization/ar.translations.mjs.map +1 -0
  11. package/esm/localization/auth-default-translations.d.mts +10 -0
  12. package/esm/localization/auth-default-translations.d.mts.map +1 -0
  13. package/esm/localization/auth-default-translations.mjs +15 -0
  14. package/esm/localization/auth-default-translations.mjs.map +1 -0
  15. package/esm/localization/auth-translations.type.d.mts +27 -0
  16. package/esm/localization/auth-translations.type.d.mts.map +1 -0
  17. package/esm/localization/en.translations.mjs +16 -0
  18. package/esm/localization/en.translations.mjs.map +1 -0
  19. package/esm/localization/index.mjs +4 -0
  20. package/esm/localization/init.mjs +8 -0
  21. package/esm/localization/init.mjs.map +1 -0
  22. package/esm/localization/register-auth-translations.d.mts +17 -0
  23. package/esm/localization/register-auth-translations.d.mts.map +1 -0
  24. package/esm/localization/register-auth-translations.mjs +29 -0
  25. package/esm/localization/register-auth-translations.mjs.map +1 -0
  26. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  27. package/esm/middleware/auth.middleware.mjs +4 -2
  28. package/esm/middleware/auth.middleware.mjs.map +1 -1
  29. package/esm/middleware/csrf-origin-check.d.mts +9 -0
  30. package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
  31. package/esm/middleware/csrf-origin-check.mjs +12 -56
  32. package/esm/middleware/csrf-origin-check.mjs.map +1 -1
  33. package/esm/middleware/localized-login-path.mjs +48 -0
  34. package/esm/middleware/localized-login-path.mjs.map +1 -0
  35. package/esm/models/access-token/access-token.model.d.mts +7 -0
  36. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  37. package/esm/models/access-token/access-token.model.mjs +3 -0
  38. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  39. package/esm/models/one-time-token/one-time-token.model.d.mts +6 -0
  40. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
  41. package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
  42. package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
  43. package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
  44. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  45. package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
  46. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  47. package/esm/providers/apple-provider.d.mts +74 -0
  48. package/esm/providers/apple-provider.d.mts.map +1 -0
  49. package/esm/providers/apple-provider.mjs +157 -0
  50. package/esm/providers/apple-provider.mjs.map +1 -0
  51. package/esm/providers/discord-provider.d.mts +25 -0
  52. package/esm/providers/discord-provider.d.mts.map +1 -0
  53. package/esm/providers/discord-provider.mjs +84 -0
  54. package/esm/providers/discord-provider.mjs.map +1 -0
  55. package/esm/providers/facebook-provider.d.mts +36 -0
  56. package/esm/providers/facebook-provider.d.mts.map +1 -0
  57. package/esm/providers/facebook-provider.mjs +94 -0
  58. package/esm/providers/facebook-provider.mjs.map +1 -0
  59. package/esm/providers/github-provider.d.mts +31 -0
  60. package/esm/providers/github-provider.d.mts.map +1 -0
  61. package/esm/providers/github-provider.mjs +115 -0
  62. package/esm/providers/github-provider.mjs.map +1 -0
  63. package/esm/providers/index.d.mts +7 -1
  64. package/esm/providers/index.mjs +6 -0
  65. package/esm/providers/linkedin-provider.d.mts +35 -0
  66. package/esm/providers/linkedin-provider.d.mts.map +1 -0
  67. package/esm/providers/linkedin-provider.mjs +108 -0
  68. package/esm/providers/linkedin-provider.mjs.map +1 -0
  69. package/esm/providers/provider-login.d.mts +2 -1
  70. package/esm/providers/provider-login.d.mts.map +1 -1
  71. package/esm/providers/provider-login.mjs +47 -7
  72. package/esm/providers/provider-login.mjs.map +1 -1
  73. package/esm/providers/provider-state-cookie.mjs +35 -16
  74. package/esm/providers/provider-state-cookie.mjs.map +1 -1
  75. package/esm/providers/x-provider.d.mts +35 -0
  76. package/esm/providers/x-provider.d.mts.map +1 -0
  77. package/esm/providers/x-provider.mjs +101 -0
  78. package/esm/providers/x-provider.mjs.map +1 -0
  79. package/esm/services/auth-config.mjs +16 -1
  80. package/esm/services/auth-config.mjs.map +1 -1
  81. package/esm/services/auth.service.mjs +1 -1
  82. package/esm/services/generate-jwt-secret.mjs +1 -1
  83. package/llms-full.txt +1075 -765
  84. package/llms.txt +5 -5
  85. package/package.json +8 -8
  86. package/skills/auth-basics/SKILL.md +16 -1
  87. package/skills/customize-token-storage/SKILL.md +160 -122
  88. package/skills/customize-user-type/SKILL.md +168 -142
  89. package/skills/handle-login-and-logout/SKILL.md +179 -165
  90. package/skills/login-with-providers/SKILL.md +131 -2
  91. package/skills/protect-routes/SKILL.md +308 -249
  92. package/skills/register-user/SKILL.md +15 -1
  93. package/skills/run-auth-commands/SKILL.md +16 -1
  94. package/skills/throttle-login-attempts/SKILL.md +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-provider.mjs","names":[],"sources":["../../../../../../../auth/src/providers/github-provider.ts"],"sourcesContent":["import type {\n AuthProvider,\n GitHubProviderConfig,\n ProviderAuthorizationState,\n ProviderCallbackParams,\n ProviderProfile,\n} from \"../contracts/providers\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport { pkceChallenge } from \"./google-provider\";\n\nconst AUTHORIZATION_ENDPOINT = \"https://github.com/login/oauth/authorize\";\nconst TOKEN_ENDPOINT = \"https://github.com/login/oauth/access_token\";\nconst USER_ENDPOINT = \"https://api.github.com/user\";\nconst EMAILS_ENDPOINT = \"https://api.github.com/user/emails\";\nconst DEFAULT_SCOPES = [\"read:user\", \"user:email\"];\n\n/** GitHub requires a `User-Agent`; identify the framework, not the app. */\nconst USER_AGENT = \"warlock.js-auth\";\n\ntype GitHubUser = {\n id: number;\n login: string;\n name?: string | null;\n avatar_url?: string;\n};\n\ntype GitHubEmail = {\n email: string;\n primary: boolean;\n verified: boolean;\n};\n\n/**\n * GitHub sign-in: plain OAuth 2 authorization code flow with PKCE (S256).\n * GitHub is not OpenID Connect — there is no `id_token` and nothing to verify\n * against a JWKS. The access token is redeemed once for the profile, over a\n * direct HTTPS call to GitHub's API; no vendor SDK is involved.\n *\n * The account email is fetched separately from `/user/emails`, because\n * `/user.email` is `null` unless the user made it public — the emails\n * endpoint is also where GitHub reports whether an address is verified.\n */\nexport class GitHubProvider implements AuthProvider {\n public readonly name = \"github\";\n\n public constructor(private readonly settings: GitHubProviderConfig) {}\n\n public authorizationUrl(state: ProviderAuthorizationState): string {\n const url = new URL(AUTHORIZATION_ENDPOINT);\n\n url.searchParams.set(\"client_id\", this.settings.clientId);\n url.searchParams.set(\"redirect_uri\", this.settings.redirectUri);\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"scope\", (this.settings.scopes ?? DEFAULT_SCOPES).join(\" \"));\n url.searchParams.set(\"state\", state.state);\n url.searchParams.set(\"code_challenge\", pkceChallenge(state.codeVerifier));\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n\n return url.toString();\n }\n\n public async handleCallback({\n query,\n expected,\n }: ProviderCallbackParams): Promise<ProviderProfile> {\n const code = query.code;\n\n if (typeof code !== \"string\" || code.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-code\");\n }\n\n const accessToken = await this.exchangeCode(code, expected.codeVerifier);\n const user = await this.fetchUser(accessToken);\n const email = await this.fetchPrimaryEmail(accessToken);\n\n if (typeof user.id !== \"number\") {\n throw new InvalidProviderCallbackError(\"missing-subject\");\n }\n\n return {\n provider: this.name,\n providerUserId: String(user.id),\n email: email?.email,\n emailVerified: email?.verified === true,\n name: user.name ?? user.login,\n avatar: user.avatar_url,\n raw: user,\n };\n }\n\n /** Redeem the authorization code (with the PKCE verifier) for an access token. */\n private async exchangeCode(code: string, codeVerifier: string): Promise<string> {\n let body: { access_token?: unknown; error?: unknown };\n\n try {\n const response = await fetch(TOKEN_ENDPOINT, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n accept: \"application/json\",\n },\n body: new URLSearchParams({\n code,\n client_id: this.settings.clientId,\n client_secret: this.settings.clientSecret,\n redirect_uri: this.settings.redirectUri,\n code_verifier: codeVerifier,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`GitHub token endpoint answered ${response.status}`);\n }\n\n body = (await response.json()) as { access_token?: unknown; error?: unknown };\n } catch (error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", error);\n }\n\n // GitHub answers 200 even on a rejected code, with `error` set instead.\n if (body.error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", body.error);\n }\n\n if (typeof body.access_token !== \"string\" || body.access_token.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-access-token\");\n }\n\n return body.access_token;\n }\n\n private async fetchUser(accessToken: string): Promise<GitHubUser> {\n try {\n const response = await fetch(USER_ENDPOINT, {\n headers: {\n authorization: `Bearer ${accessToken}`,\n accept: \"application/vnd.github+json\",\n \"user-agent\": USER_AGENT,\n },\n });\n\n if (!response.ok) {\n throw new Error(`GitHub user endpoint answered ${response.status}`);\n }\n\n return (await response.json()) as GitHubUser;\n } catch (error) {\n throw new InvalidProviderCallbackError(\"profile-fetch-failed\", error);\n }\n }\n\n /** The primary, verified email — `undefined` when none is both primary and verified. */\n private async fetchPrimaryEmail(accessToken: string): Promise<GitHubEmail | undefined> {\n let emails: GitHubEmail[];\n\n try {\n const response = await fetch(EMAILS_ENDPOINT, {\n headers: {\n authorization: `Bearer ${accessToken}`,\n accept: \"application/vnd.github+json\",\n \"user-agent\": USER_AGENT,\n },\n });\n\n if (!response.ok) {\n throw new Error(`GitHub emails endpoint answered ${response.status}`);\n }\n\n emails = (await response.json()) as GitHubEmail[];\n } catch (error) {\n throw new InvalidProviderCallbackError(\"profile-fetch-failed\", error);\n }\n\n return emails.find((email) => email.primary && email.verified);\n }\n}\n"],"mappings":";;;;AAUA,MAAM,yBAAyB;AAC/B,MAAM,iBAAiB;AACvB,MAAM,gBAAgB;AACtB,MAAM,kBAAkB;AACxB,MAAM,iBAAiB,CAAC,aAAa,YAAY;;AAGjD,MAAM,aAAa;;;;;;;;;;;AAyBnB,IAAa,iBAAb,MAAoD;CAGlD,AAAO,YAAY,AAAiB,UAAgC;EAAhC;cAFb;CAE8C;CAErE,AAAO,iBAAiB,OAA2C;EACjE,MAAM,MAAM,IAAI,IAAI,sBAAsB;EAE1C,IAAI,aAAa,IAAI,aAAa,KAAK,SAAS,QAAQ;EACxD,IAAI,aAAa,IAAI,gBAAgB,KAAK,SAAS,WAAW;EAC9D,IAAI,aAAa,IAAI,iBAAiB,MAAM;EAC5C,IAAI,aAAa,IAAI,UAAU,KAAK,SAAS,UAAU,eAAc,CAAE,KAAK,GAAG,CAAC;EAChF,IAAI,aAAa,IAAI,SAAS,MAAM,KAAK;EACzC,IAAI,aAAa,IAAI,kBAAkB,cAAc,MAAM,YAAY,CAAC;EACxE,IAAI,aAAa,IAAI,yBAAyB,MAAM;EAEpD,OAAO,IAAI,SAAS;CACtB;CAEA,MAAa,eAAe,EAC1B,OACA,YACmD;EACnD,MAAM,OAAO,MAAM;EAEnB,IAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAC9C,MAAM,IAAI,6BAA6B,cAAc;EAGvD,MAAM,cAAc,MAAM,KAAK,aAAa,MAAM,SAAS,YAAY;EACvE,MAAM,OAAO,MAAM,KAAK,UAAU,WAAW;EAC7C,MAAM,QAAQ,MAAM,KAAK,kBAAkB,WAAW;EAEtD,IAAI,OAAO,KAAK,OAAO,UACrB,MAAM,IAAI,6BAA6B,iBAAiB;EAG1D,OAAO;GACL,UAAU,KAAK;GACf,gBAAgB,OAAO,KAAK,EAAE;GAC9B,OAAO,OAAO;GACd,eAAe,OAAO,aAAa;GACnC,MAAM,KAAK,QAAQ,KAAK;GACxB,QAAQ,KAAK;GACb,KAAK;EACP;CACF;;CAGA,MAAc,aAAa,MAAc,cAAuC;EAC9E,IAAI;EAEJ,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,gBAAgB;IAC3C,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,QAAQ;IACV;IACA,MAAM,IAAI,gBAAgB;KACxB;KACA,WAAW,KAAK,SAAS;KACzB,eAAe,KAAK,SAAS;KAC7B,cAAc,KAAK,SAAS;KAC5B,eAAe;IACjB,CAAC;GACH,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,kCAAkC,SAAS,QAAQ;GAGrE,OAAQ,MAAM,SAAS,KAAK;EAC9B,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;EAGA,IAAI,KAAK,OACP,MAAM,IAAI,6BAA6B,wBAAwB,KAAK,KAAK;EAG3E,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,WAAW,GACxE,MAAM,IAAI,6BAA6B,sBAAsB;EAG/D,OAAO,KAAK;CACd;CAEA,MAAc,UAAU,aAA0C;EAChE,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,eAAe,EAC1C,SAAS;IACP,eAAe,UAAU;IACzB,QAAQ;IACR,cAAc;GAChB,EACF,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,iCAAiC,SAAS,QAAQ;GAGpE,OAAQ,MAAM,SAAS,KAAK;EAC9B,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;CACF;;CAGA,MAAc,kBAAkB,aAAuD;EACrF,IAAI;EAEJ,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,iBAAiB,EAC5C,SAAS;IACP,eAAe,UAAU;IACzB,QAAQ;IACR,cAAc;GAChB,EACF,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,mCAAmC,SAAS,QAAQ;GAGtE,SAAU,MAAM,SAAS,KAAK;EAChC,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;EAEA,OAAO,OAAO,MAAM,UAAU,MAAM,WAAW,MAAM,QAAQ;CAC/D;AACF"}
@@ -1,2 +1,8 @@
1
1
  import { GOOGLE_AUTH_FEATURE, GoogleProvider, JoseModule, pkceChallenge } from "./google-provider.mjs";
2
- import { completeProviderLogin, resolveAuthProvider, startProviderLogin } from "./provider-login.mjs";
2
+ import { APPLE_AUTH_FEATURE, AppleJoseModule, AppleProvider } from "./apple-provider.mjs";
3
+ import { DiscordProvider } from "./discord-provider.mjs";
4
+ import { FacebookProvider } from "./facebook-provider.mjs";
5
+ import { GitHubProvider } from "./github-provider.mjs";
6
+ import { LINKEDIN_AUTH_FEATURE, LinkedInProvider } from "./linkedin-provider.mjs";
7
+ import { completeProviderLogin, resolveAuthProvider, startProviderLogin } from "./provider-login.mjs";
8
+ import { XProvider } from "./x-provider.mjs";
@@ -1,4 +1,10 @@
1
1
  import { GOOGLE_AUTH_FEATURE, GoogleProvider, pkceChallenge } from "./google-provider.mjs";
2
+ import { APPLE_AUTH_FEATURE, AppleProvider } from "./apple-provider.mjs";
3
+ import { DiscordProvider } from "./discord-provider.mjs";
4
+ import { FacebookProvider } from "./facebook-provider.mjs";
5
+ import { GitHubProvider } from "./github-provider.mjs";
6
+ import { LINKEDIN_AUTH_FEATURE, LinkedInProvider } from "./linkedin-provider.mjs";
7
+ import { XProvider } from "./x-provider.mjs";
2
8
  import { completeProviderLogin, resolveAuthProvider, startProviderLogin } from "./provider-login.mjs";
3
9
 
4
10
  export { };
@@ -0,0 +1,35 @@
1
+ import { AuthProvider, LinkedInProviderConfig, ProviderAuthorizationState, ProviderCallbackParams, ProviderProfile } from "../contracts/providers.mjs";
2
+
3
+ //#region ../auth/src/providers/linkedin-provider.d.ts
4
+ /** The `warlock add` feature that installs `jose`. */
5
+ declare const LINKEDIN_AUTH_FEATURE = "auth-linkedin";
6
+ /**
7
+ * LinkedIn sign-in: OpenID Connect authorization code flow with PKCE (S256)
8
+ * and `state` and `nonce` — LinkedIn's "Sign In with LinkedIn using OpenID
9
+ * Connect" product, the same shape as {@link GoogleProvider}. The code is
10
+ * exchanged with plain `fetch`; the id_token is verified with `jose` against
11
+ * LinkedIn's JWKS (signature, `iss`, `aud` = the client id, `exp`), then its
12
+ * `nonce` is compared. No LinkedIn SDK.
13
+ *
14
+ * The exact issuer string LinkedIn signs (`ISSUERS` above) and whether its
15
+ * token endpoint honours `code_verifier` are asserted from LinkedIn's public
16
+ * OIDC docs, not from a live exchange — this package has no LinkedIn test
17
+ * credentials. See the worker report for what is and is not proven.
18
+ */
19
+ declare class LinkedInProvider implements AuthProvider {
20
+ private readonly settings;
21
+ readonly name = "linkedin";
22
+ constructor(settings: LinkedInProviderConfig);
23
+ authorizationUrl(state: ProviderAuthorizationState): string;
24
+ handleCallback({
25
+ query,
26
+ expected
27
+ }: ProviderCallbackParams): Promise<ProviderProfile>;
28
+ /** Redeem the authorization code (with the PKCE verifier) for an id_token. */
29
+ private exchangeCode;
30
+ /** Verify signature, issuer, audience and expiry; resolve the claims. */
31
+ private verifyIdToken;
32
+ }
33
+ //#endregion
34
+ export { LINKEDIN_AUTH_FEATURE, LinkedInProvider };
35
+ //# sourceMappingURL=linkedin-provider.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linkedin-provider.d.mts","names":[],"sources":["../../../../../../../auth/src/providers/linkedin-provider.ts"],"mappings":";;;;cAmBa,qBAAA;AAAb;;;;AAAkC;AAkBlC;;;;;;;;AAlBA,cAkBa,gBAAA,YAA4B,YAAA;EAAA,iBAGH,QAAA;EAAA,SAFpB,IAAA;cAEoB,QAAA,EAAU,sBAAA;EAEvC,gBAAA,CAAiB,KAAA,EAAO,0BAAA;EAelB,cAAA;IACX,KAAA;IACA;EAAA,GACC,sBAAA,GAAyB,OAAA,CAAQ,eAAA;EApBA;EAAA,QAsDtB,YAAA;;UAkCA,aAAA;AAAA"}
@@ -0,0 +1,108 @@
1
+ import { InvalidProviderCallbackError } from "../errors/invalid-provider-callback.error.mjs";
2
+ import { loadOptionalPeer } from "../services/optional-peer.mjs";
3
+ import { pkceChallenge } from "./google-provider.mjs";
4
+
5
+ //#region ../auth/src/providers/linkedin-provider.ts
6
+ const AUTHORIZATION_ENDPOINT = "https://www.linkedin.com/oauth/v2/authorization";
7
+ const TOKEN_ENDPOINT = "https://www.linkedin.com/oauth/v2/accessToken";
8
+ const JWKS_URI = "https://www.linkedin.com/oauth/openid/jwks";
9
+ const ISSUERS = ["https://www.linkedin.com", "https://www.linkedin.com/oauth"];
10
+ const DEFAULT_SCOPES = [
11
+ "openid",
12
+ "profile",
13
+ "email"
14
+ ];
15
+ /** The `warlock add` feature that installs `jose`. */
16
+ const LINKEDIN_AUTH_FEATURE = "auth-linkedin";
17
+ /** LinkedIn's key set, created once per process; `jose` caches the fetched JWKS inside it. */
18
+ let linkedinKeySet;
19
+ /**
20
+ * LinkedIn sign-in: OpenID Connect authorization code flow with PKCE (S256)
21
+ * and `state` and `nonce` — LinkedIn's "Sign In with LinkedIn using OpenID
22
+ * Connect" product, the same shape as {@link GoogleProvider}. The code is
23
+ * exchanged with plain `fetch`; the id_token is verified with `jose` against
24
+ * LinkedIn's JWKS (signature, `iss`, `aud` = the client id, `exp`), then its
25
+ * `nonce` is compared. No LinkedIn SDK.
26
+ *
27
+ * The exact issuer string LinkedIn signs (`ISSUERS` above) and whether its
28
+ * token endpoint honours `code_verifier` are asserted from LinkedIn's public
29
+ * OIDC docs, not from a live exchange — this package has no LinkedIn test
30
+ * credentials. See the worker report for what is and is not proven.
31
+ */
32
+ var LinkedInProvider = class {
33
+ constructor(settings) {
34
+ this.settings = settings;
35
+ this.name = "linkedin";
36
+ }
37
+ authorizationUrl(state) {
38
+ const url = new URL(AUTHORIZATION_ENDPOINT);
39
+ url.searchParams.set("client_id", this.settings.clientId);
40
+ url.searchParams.set("redirect_uri", this.settings.redirectUri);
41
+ url.searchParams.set("response_type", "code");
42
+ url.searchParams.set("scope", (this.settings.scopes ?? DEFAULT_SCOPES).join(" "));
43
+ url.searchParams.set("state", state.state);
44
+ url.searchParams.set("nonce", state.nonce);
45
+ url.searchParams.set("code_challenge", pkceChallenge(state.codeVerifier));
46
+ url.searchParams.set("code_challenge_method", "S256");
47
+ return url.toString();
48
+ }
49
+ async handleCallback({ query, expected }) {
50
+ const jose = await loadOptionalPeer("jose", LINKEDIN_AUTH_FEATURE);
51
+ const code = query.code;
52
+ if (typeof code !== "string" || code.length === 0) throw new InvalidProviderCallbackError("missing-code");
53
+ const idToken = await this.exchangeCode(code, expected.codeVerifier);
54
+ const claims = await this.verifyIdToken(jose, idToken);
55
+ if (typeof claims.nonce !== "string" || claims.nonce !== expected.nonce) throw new InvalidProviderCallbackError("nonce-mismatch");
56
+ if (typeof claims.sub !== "string" || claims.sub.length === 0) throw new InvalidProviderCallbackError("missing-subject");
57
+ return {
58
+ provider: this.name,
59
+ providerUserId: claims.sub,
60
+ email: typeof claims.email === "string" ? claims.email : void 0,
61
+ emailVerified: claims.email_verified === true,
62
+ name: typeof claims.name === "string" ? claims.name : void 0,
63
+ avatar: typeof claims.picture === "string" ? claims.picture : void 0,
64
+ raw: claims
65
+ };
66
+ }
67
+ /** Redeem the authorization code (with the PKCE verifier) for an id_token. */
68
+ async exchangeCode(code, codeVerifier) {
69
+ let body;
70
+ try {
71
+ const response = await fetch(TOKEN_ENDPOINT, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/x-www-form-urlencoded" },
74
+ body: new URLSearchParams({
75
+ code,
76
+ client_id: this.settings.clientId,
77
+ client_secret: this.settings.clientSecret,
78
+ redirect_uri: this.settings.redirectUri,
79
+ grant_type: "authorization_code",
80
+ code_verifier: codeVerifier
81
+ })
82
+ });
83
+ if (!response.ok) throw new Error(`LinkedIn token endpoint answered ${response.status}`);
84
+ body = await response.json();
85
+ } catch (error) {
86
+ throw new InvalidProviderCallbackError("code-exchange-failed", error);
87
+ }
88
+ if (typeof body.id_token !== "string") throw new InvalidProviderCallbackError("missing-id-token");
89
+ return body.id_token;
90
+ }
91
+ /** Verify signature, issuer, audience and expiry; resolve the claims. */
92
+ async verifyIdToken(jose, idToken) {
93
+ linkedinKeySet ??= jose.createRemoteJWKSet(new URL(JWKS_URI));
94
+ try {
95
+ const { payload } = await jose.jwtVerify(idToken, linkedinKeySet, {
96
+ issuer: ISSUERS,
97
+ audience: this.settings.clientId
98
+ });
99
+ return payload;
100
+ } catch (error) {
101
+ throw new InvalidProviderCallbackError("invalid-id-token", error);
102
+ }
103
+ }
104
+ };
105
+
106
+ //#endregion
107
+ export { LINKEDIN_AUTH_FEATURE, LinkedInProvider };
108
+ //# sourceMappingURL=linkedin-provider.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linkedin-provider.mjs","names":[],"sources":["../../../../../../../auth/src/providers/linkedin-provider.ts"],"sourcesContent":["import type {\n AuthProvider,\n LinkedInProviderConfig,\n ProviderAuthorizationState,\n ProviderCallbackParams,\n ProviderProfile,\n} from \"../contracts/providers\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport { loadOptionalPeer } from \"../services/optional-peer\";\nimport type { JoseModule } from \"./google-provider\";\nimport { pkceChallenge } from \"./google-provider\";\n\nconst AUTHORIZATION_ENDPOINT = \"https://www.linkedin.com/oauth/v2/authorization\";\nconst TOKEN_ENDPOINT = \"https://www.linkedin.com/oauth/v2/accessToken\";\nconst JWKS_URI = \"https://www.linkedin.com/oauth/openid/jwks\";\nconst ISSUERS = [\"https://www.linkedin.com\", \"https://www.linkedin.com/oauth\"];\nconst DEFAULT_SCOPES = [\"openid\", \"profile\", \"email\"];\n\n/** The `warlock add` feature that installs `jose`. */\nexport const LINKEDIN_AUTH_FEATURE = \"auth-linkedin\";\n\n/** LinkedIn's key set, created once per process; `jose` caches the fetched JWKS inside it. */\nlet linkedinKeySet: unknown;\n\n/**\n * LinkedIn sign-in: OpenID Connect authorization code flow with PKCE (S256)\n * and `state` and `nonce` — LinkedIn's \"Sign In with LinkedIn using OpenID\n * Connect\" product, the same shape as {@link GoogleProvider}. The code is\n * exchanged with plain `fetch`; the id_token is verified with `jose` against\n * LinkedIn's JWKS (signature, `iss`, `aud` = the client id, `exp`), then its\n * `nonce` is compared. No LinkedIn SDK.\n *\n * The exact issuer string LinkedIn signs (`ISSUERS` above) and whether its\n * token endpoint honours `code_verifier` are asserted from LinkedIn's public\n * OIDC docs, not from a live exchange — this package has no LinkedIn test\n * credentials. See the worker report for what is and is not proven.\n */\nexport class LinkedInProvider implements AuthProvider {\n public readonly name = \"linkedin\";\n\n public constructor(private readonly settings: LinkedInProviderConfig) {}\n\n public authorizationUrl(state: ProviderAuthorizationState): string {\n const url = new URL(AUTHORIZATION_ENDPOINT);\n\n url.searchParams.set(\"client_id\", this.settings.clientId);\n url.searchParams.set(\"redirect_uri\", this.settings.redirectUri);\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"scope\", (this.settings.scopes ?? DEFAULT_SCOPES).join(\" \"));\n url.searchParams.set(\"state\", state.state);\n url.searchParams.set(\"nonce\", state.nonce);\n url.searchParams.set(\"code_challenge\", pkceChallenge(state.codeVerifier));\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n\n return url.toString();\n }\n\n public async handleCallback({\n query,\n expected,\n }: ProviderCallbackParams): Promise<ProviderProfile> {\n // Load the peer first: a missing SDK fails loudly before any network call.\n const jose = await loadOptionalPeer<JoseModule>(\"jose\", LINKEDIN_AUTH_FEATURE);\n\n const code = query.code;\n\n if (typeof code !== \"string\" || code.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-code\");\n }\n\n const idToken = await this.exchangeCode(code, expected.codeVerifier);\n const claims = await this.verifyIdToken(jose, idToken);\n\n if (typeof claims.nonce !== \"string\" || claims.nonce !== expected.nonce) {\n throw new InvalidProviderCallbackError(\"nonce-mismatch\");\n }\n\n if (typeof claims.sub !== \"string\" || claims.sub.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-subject\");\n }\n\n return {\n provider: this.name,\n providerUserId: claims.sub,\n email: typeof claims.email === \"string\" ? claims.email : undefined,\n // LinkedIn sends a boolean; anything else counts as NOT verified.\n emailVerified: claims.email_verified === true,\n name: typeof claims.name === \"string\" ? claims.name : undefined,\n avatar: typeof claims.picture === \"string\" ? claims.picture : undefined,\n raw: claims,\n };\n }\n\n /** Redeem the authorization code (with the PKCE verifier) for an id_token. */\n private async exchangeCode(code: string, codeVerifier: string): Promise<string> {\n let body: { id_token?: unknown };\n\n try {\n const response = await fetch(TOKEN_ENDPOINT, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n code,\n client_id: this.settings.clientId,\n client_secret: this.settings.clientSecret,\n redirect_uri: this.settings.redirectUri,\n grant_type: \"authorization_code\",\n code_verifier: codeVerifier,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`LinkedIn token endpoint answered ${response.status}`);\n }\n\n body = (await response.json()) as { id_token?: unknown };\n } catch (error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", error);\n }\n\n if (typeof body.id_token !== \"string\") {\n throw new InvalidProviderCallbackError(\"missing-id-token\");\n }\n\n return body.id_token;\n }\n\n /** Verify signature, issuer, audience and expiry; resolve the claims. */\n private async verifyIdToken(jose: JoseModule, idToken: string): Promise<Record<string, unknown>> {\n linkedinKeySet ??= jose.createRemoteJWKSet(new URL(JWKS_URI));\n\n try {\n const { payload } = await jose.jwtVerify(idToken, linkedinKeySet, {\n issuer: ISSUERS,\n audience: this.settings.clientId,\n });\n\n return payload;\n } catch (error) {\n throw new InvalidProviderCallbackError(\"invalid-id-token\", error);\n }\n }\n}\n"],"mappings":";;;;;AAYA,MAAM,yBAAyB;AAC/B,MAAM,iBAAiB;AACvB,MAAM,WAAW;AACjB,MAAM,UAAU,CAAC,4BAA4B,gCAAgC;AAC7E,MAAM,iBAAiB;CAAC;CAAU;CAAW;AAAO;;AAGpD,MAAa,wBAAwB;;AAGrC,IAAI;;;;;;;;;;;;;;AAeJ,IAAa,mBAAb,MAAsD;CAGpD,AAAO,YAAY,AAAiB,UAAkC;EAAlC;cAFb;CAEgD;CAEvE,AAAO,iBAAiB,OAA2C;EACjE,MAAM,MAAM,IAAI,IAAI,sBAAsB;EAE1C,IAAI,aAAa,IAAI,aAAa,KAAK,SAAS,QAAQ;EACxD,IAAI,aAAa,IAAI,gBAAgB,KAAK,SAAS,WAAW;EAC9D,IAAI,aAAa,IAAI,iBAAiB,MAAM;EAC5C,IAAI,aAAa,IAAI,UAAU,KAAK,SAAS,UAAU,eAAc,CAAE,KAAK,GAAG,CAAC;EAChF,IAAI,aAAa,IAAI,SAAS,MAAM,KAAK;EACzC,IAAI,aAAa,IAAI,SAAS,MAAM,KAAK;EACzC,IAAI,aAAa,IAAI,kBAAkB,cAAc,MAAM,YAAY,CAAC;EACxE,IAAI,aAAa,IAAI,yBAAyB,MAAM;EAEpD,OAAO,IAAI,SAAS;CACtB;CAEA,MAAa,eAAe,EAC1B,OACA,YACmD;EAEnD,MAAM,OAAO,MAAM,iBAA6B,QAAQ,qBAAqB;EAE7E,MAAM,OAAO,MAAM;EAEnB,IAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAC9C,MAAM,IAAI,6BAA6B,cAAc;EAGvD,MAAM,UAAU,MAAM,KAAK,aAAa,MAAM,SAAS,YAAY;EACnE,MAAM,SAAS,MAAM,KAAK,cAAc,MAAM,OAAO;EAErD,IAAI,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,SAAS,OAChE,MAAM,IAAI,6BAA6B,gBAAgB;EAGzD,IAAI,OAAO,OAAO,QAAQ,YAAY,OAAO,IAAI,WAAW,GAC1D,MAAM,IAAI,6BAA6B,iBAAiB;EAG1D,OAAO;GACL,UAAU,KAAK;GACf,gBAAgB,OAAO;GACvB,OAAO,OAAO,OAAO,UAAU,WAAW,OAAO,QAAQ;GAEzD,eAAe,OAAO,mBAAmB;GACzC,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;GACtD,QAAQ,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;GAC9D,KAAK;EACP;CACF;;CAGA,MAAc,aAAa,MAAc,cAAuC;EAC9E,IAAI;EAEJ,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,gBAAgB;IAC3C,QAAQ;IACR,SAAS,EAAE,gBAAgB,oCAAoC;IAC/D,MAAM,IAAI,gBAAgB;KACxB;KACA,WAAW,KAAK,SAAS;KACzB,eAAe,KAAK,SAAS;KAC7B,cAAc,KAAK,SAAS;KAC5B,YAAY;KACZ,eAAe;IACjB,CAAC;GACH,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,oCAAoC,SAAS,QAAQ;GAGvE,OAAQ,MAAM,SAAS,KAAK;EAC9B,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;EAEA,IAAI,OAAO,KAAK,aAAa,UAC3B,MAAM,IAAI,6BAA6B,kBAAkB;EAG3D,OAAO,KAAK;CACd;;CAGA,MAAc,cAAc,MAAkB,SAAmD;EAC/F,mBAAmB,KAAK,mBAAmB,IAAI,IAAI,QAAQ,CAAC;EAE5D,IAAI;GACF,MAAM,EAAE,YAAY,MAAM,KAAK,UAAU,SAAS,gBAAgB;IAChE,QAAQ;IACR,UAAU,KAAK,SAAS;GAC1B,CAAC;GAED,OAAO;EACT,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,oBAAoB,KAAK;EAClE;CACF;AACF"}
@@ -6,7 +6,8 @@ import { ChildModel } from "@warlock.js/cascade";
6
6
 
7
7
  //#region ../auth/src/providers/provider-login.d.ts
8
8
  /**
9
- * Resolve a provider by name: `google` from `auth.providers.google`, anything
9
+ * Resolve a provider by name: a built-in name (`google`, `github`, `discord`,
10
+ * `linkedin`, `apple`, `facebook`, `x`) from `auth.providers.<name>`, anything
10
11
  * else from `auth.providers.custom`. Throws naming the config key when absent.
11
12
  */
12
13
  declare function resolveAuthProvider(name: string): AuthProvider;
@@ -1 +1 @@
1
- {"version":3,"file":"provider-login.d.mts","names":[],"sources":["../../../../../../../auth/src/providers/provider-login.ts"],"mappings":";;;;;;;;;AAiBA;;iBAAgB,mBAAA,CAAoB,IAAA,WAAe,YAAY;;AAAA;AA4B/D;;;;;;;;iBAAsB,kBAAA,CACpB,QAAA,EAAU,QAAA,EACV,YAAA,WACC,OAAO;AAAA;AAgCV;;;;;;;;;;;;;;;AAhCU,iBAgCY,qBAAA,WAAgC,IAAA,EACpD,KAAA,EAAO,UAAA,CAAW,CAAA,GAClB,YAAA,UACA,OAAA,EAAS,OAAA,EACT,QAAA,EAAU,QAAA,EACV,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,WAAA,CAAY,CAAA"}
1
+ {"version":3,"file":"provider-login.d.mts","names":[],"sources":["../../../../../../../auth/src/providers/provider-login.ts"],"mappings":";;;;;;;;;AA+DA;;;iBAAgB,mBAAA,CAAoB,IAAA,WAAe,YAAY;AAAA;AA8B/D;;;;;;;;;AA9B+D,iBA8BzC,kBAAA,CACpB,QAAA,EAAU,QAAA,EACV,YAAA,WACC,OAAO;AAgCV;;;;;;;;;;;;;;;;AAAA,iBAAsB,qBAAA,WAAgC,IAAA,EACpD,KAAA,EAAO,UAAA,CAAW,CAAA,GAClB,YAAA,UACA,OAAA,EAAS,OAAA,EACT,QAAA,EAAU,QAAA,EACV,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,WAAA,CAAY,CAAA"}
@@ -3,23 +3,62 @@ import { authService } from "../services/auth.service.mjs";
3
3
  import { InvalidProviderCallbackError } from "../errors/invalid-provider-callback.error.mjs";
4
4
  import { safeEqual } from "../utils/safe-equal.mjs";
5
5
  import { GoogleProvider } from "./google-provider.mjs";
6
+ import { AppleProvider } from "./apple-provider.mjs";
7
+ import { DiscordProvider } from "./discord-provider.mjs";
8
+ import { FacebookProvider } from "./facebook-provider.mjs";
9
+ import { GitHubProvider } from "./github-provider.mjs";
10
+ import { LinkedInProvider } from "./linkedin-provider.mjs";
11
+ import { XProvider } from "./x-provider.mjs";
6
12
  import { randomUrlSafe, takeProviderState, writeProviderState } from "./provider-state-cookie.mjs";
7
13
  import { resolveProviderUser } from "./resolve-provider-user.mjs";
8
14
 
9
15
  //#region ../auth/src/providers/provider-login.ts
16
+ /** Providers auth ships, by name — each built from `auth.providers.<name>`. */
17
+ const BUILT_IN_PROVIDERS = {
18
+ google: () => {
19
+ const google = authConfig.providers.google();
20
+ return google ? new GoogleProvider(google) : void 0;
21
+ },
22
+ github: () => {
23
+ const github = authConfig.providers.github();
24
+ return github ? new GitHubProvider(github) : void 0;
25
+ },
26
+ discord: () => {
27
+ const discord = authConfig.providers.discord();
28
+ return discord ? new DiscordProvider(discord) : void 0;
29
+ },
30
+ linkedin: () => {
31
+ const linkedin = authConfig.providers.linkedin();
32
+ return linkedin ? new LinkedInProvider(linkedin) : void 0;
33
+ },
34
+ apple: () => {
35
+ const apple = authConfig.providers.apple();
36
+ return apple ? new AppleProvider(apple) : void 0;
37
+ },
38
+ facebook: () => {
39
+ const facebook = authConfig.providers.facebook();
40
+ return facebook ? new FacebookProvider(facebook) : void 0;
41
+ },
42
+ x: () => {
43
+ const x = authConfig.providers.x();
44
+ return x ? new XProvider(x) : void 0;
45
+ }
46
+ };
10
47
  /**
11
- * Resolve a provider by name: `google` from `auth.providers.google`, anything
48
+ * Resolve a provider by name: a built-in name (`google`, `github`, `discord`,
49
+ * `linkedin`, `apple`, `facebook`, `x`) from `auth.providers.<name>`, anything
12
50
  * else from `auth.providers.custom`. Throws naming the config key when absent.
13
51
  */
14
52
  function resolveAuthProvider(name) {
15
- if (name === "google") {
16
- const google = authConfig.providers.google();
17
- if (google) return new GoogleProvider(google);
53
+ const isBuiltIn = Object.hasOwn(BUILT_IN_PROVIDERS, name);
54
+ if (isBuiltIn) {
55
+ const provider = BUILT_IN_PROVIDERS[name](name);
56
+ if (provider) return provider;
18
57
  } else {
19
58
  const custom = authConfig.providers.custom(name);
20
59
  if (custom) return custom;
21
60
  }
22
- throw new Error(`@warlock.js/auth: no login provider "${name}" — configure \`auth.providers.${name === "google" ? "google" : `custom.${name}`}\`.`);
61
+ throw new Error(`@warlock.js/auth: no login provider "${name}" — configure \`auth.providers.${isBuiltIn ? name : `custom.${name}`}\`.`);
23
62
  }
24
63
  /**
25
64
  * Start a redirect login: generate `state`, `nonce` and a PKCE verifier, store
@@ -39,7 +78,7 @@ async function startProviderLogin(response, providerName) {
39
78
  codeVerifier: randomUrlSafe()
40
79
  };
41
80
  const url = await provider.authorizationUrl(state);
42
- writeProviderState(response, provider.name, state);
81
+ writeProviderState(response, provider.name, state, provider.callbackMode ?? "query");
43
82
  return url;
44
83
  }
45
84
  /**
@@ -66,7 +105,8 @@ async function completeProviderLogin(Model, providerName, request, response, dev
66
105
  const query = {
67
106
  code: request.input("code"),
68
107
  state: request.input("state"),
69
- error: request.input("error")
108
+ error: request.input("error"),
109
+ user: request.input("user")
70
110
  };
71
111
  if (query.error) throw new InvalidProviderCallbackError(`provider-error:${String(query.error)}`);
72
112
  if (typeof query.state !== "string" || !safeEqual(query.state, expected.state)) throw new InvalidProviderCallbackError("state-mismatch");
@@ -1 +1 @@
1
- {"version":3,"file":"provider-login.mjs","names":[],"sources":["../../../../../../../auth/src/providers/provider-login.ts"],"sourcesContent":["import type { ChildModel } from \"@warlock.js/cascade\";\nimport type { Request, Response } from \"@warlock.js/core\";\nimport type { AuthProvider } from \"../contracts/providers\";\nimport type { DeviceInfo, LoginResult } from \"../contracts/types\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport type { Auth } from \"../models/auth.model\";\nimport { authConfig } from \"../services/auth-config\";\nimport { authService } from \"../services/auth.service\";\nimport { GoogleProvider } from \"./google-provider\";\nimport { safeEqual } from \"../utils/safe-equal\";\nimport { randomUrlSafe, takeProviderState, writeProviderState } from \"./provider-state-cookie\";\nimport { resolveProviderUser } from \"./resolve-provider-user\";\n\n/**\n * Resolve a provider by name: `google` from `auth.providers.google`, anything\n * else from `auth.providers.custom`. Throws naming the config key when absent.\n */\nexport function resolveAuthProvider(name: string): AuthProvider {\n if (name === \"google\") {\n const google = authConfig.providers.google();\n\n if (google) return new GoogleProvider(google);\n } else {\n const custom = authConfig.providers.custom(name);\n\n if (custom) return custom;\n }\n\n throw new Error(\n `@warlock.js/auth: no login provider \"${name}\" — configure \\`auth.providers.${\n name === \"google\" ? \"google\" : `custom.${name}`\n }\\`.`,\n );\n}\n\n/**\n * Start a redirect login: generate `state`, `nonce` and a PKCE verifier, store\n * them in a signed 10-minute cookie, and resolve the provider URL to redirect\n * the browser to.\n *\n * @example\n * // GET /auth/google\n * export const googleRedirect: RequestHandler = async (_request, response) =>\n * response.redirect(await startProviderLogin(response, \"google\"));\n */\nexport async function startProviderLogin(\n response: Response,\n providerName: string,\n): Promise<string> {\n const provider = resolveAuthProvider(providerName);\n\n const state = {\n state: randomUrlSafe(),\n nonce: randomUrlSafe(),\n codeVerifier: randomUrlSafe(),\n };\n\n const url = await provider.authorizationUrl(state);\n\n writeProviderState(response, provider.name, state);\n\n return url;\n}\n\n/**\n * Finish a redirect login on the provider's callback: consume the state\n * cookie, reject a missing/forged/expired cookie, a cookie from another\n * provider, a provider-reported error or a `state` mismatch; let the provider\n * verify the callback; find-or-link the user; then log them in through\n * `authService.completeLogin` — the same outcome as password login.\n *\n * @throws InvalidProviderCallbackError (400, EC009) for any callback rejection.\n * @throws ProviderEmailNotVerifiedError (403, EC010) for an unlinked, unverified email.\n * @throws AuthProviderSdkMissingError when the provider's SDK is not installed.\n *\n * @example\n * // GET /auth/google/callback\n * const { user, tokens } = await completeProviderLogin(User, \"google\", request, response);\n * authService.setAuthCookie(response, tokens.accessToken);\n */\nexport async function completeProviderLogin<T extends Auth>(\n Model: ChildModel<T>,\n providerName: string,\n request: Request,\n response: Response,\n deviceInfo?: DeviceInfo,\n): Promise<LoginResult<T>> {\n const provider = resolveAuthProvider(providerName);\n const expected = takeProviderState(request, response);\n\n if (!expected) {\n throw new InvalidProviderCallbackError(\"missing-or-invalid-state-cookie\");\n }\n\n if (expected.provider !== provider.name) {\n throw new InvalidProviderCallbackError(\"state-cookie-for-another-provider\");\n }\n\n const query: Record<string, unknown> = {\n code: request.input(\"code\"),\n state: request.input(\"state\"),\n error: request.input(\"error\"),\n };\n\n if (query.error) {\n throw new InvalidProviderCallbackError(`provider-error:${String(query.error)}`);\n }\n\n if (typeof query.state !== \"string\" || !safeEqual(query.state, expected.state)) {\n throw new InvalidProviderCallbackError(\"state-mismatch\");\n }\n\n const profile = await provider.handleCallback({\n query,\n expected: { state: expected.state, nonce: expected.nonce, codeVerifier: expected.codeVerifier },\n });\n\n const user = await resolveProviderUser(Model, profile);\n\n return authService.completeLogin(user, deviceInfo);\n}\n"],"mappings":";;;;;;;;;;;;;AAiBA,SAAgB,oBAAoB,MAA4B;CAC9D,IAAI,SAAS,UAAU;EACrB,MAAM,SAAS,WAAW,UAAU,OAAO;EAE3C,IAAI,QAAQ,OAAO,IAAI,eAAe,MAAM;CAC9C,OAAO;EACL,MAAM,SAAS,WAAW,UAAU,OAAO,IAAI;EAE/C,IAAI,QAAQ,OAAO;CACrB;CAEA,MAAM,IAAI,MACR,wCAAwC,KAAK,iCAC3C,SAAS,WAAW,WAAW,UAAU,OAC1C,IACH;AACF;;;;;;;;;;;AAYA,eAAsB,mBACpB,UACA,cACiB;CACjB,MAAM,WAAW,oBAAoB,YAAY;CAEjD,MAAM,QAAQ;EACZ,OAAO,cAAc;EACrB,OAAO,cAAc;EACrB,cAAc,cAAc;CAC9B;CAEA,MAAM,MAAM,MAAM,SAAS,iBAAiB,KAAK;CAEjD,mBAAmB,UAAU,SAAS,MAAM,KAAK;CAEjD,OAAO;AACT;;;;;;;;;;;;;;;;;AAkBA,eAAsB,sBACpB,OACA,cACA,SACA,UACA,YACyB;CACzB,MAAM,WAAW,oBAAoB,YAAY;CACjD,MAAM,WAAW,kBAAkB,SAAS,QAAQ;CAEpD,IAAI,CAAC,UACH,MAAM,IAAI,6BAA6B,iCAAiC;CAG1E,IAAI,SAAS,aAAa,SAAS,MACjC,MAAM,IAAI,6BAA6B,mCAAmC;CAG5E,MAAM,QAAiC;EACrC,MAAM,QAAQ,MAAM,MAAM;EAC1B,OAAO,QAAQ,MAAM,OAAO;EAC5B,OAAO,QAAQ,MAAM,OAAO;CAC9B;CAEA,IAAI,MAAM,OACR,MAAM,IAAI,6BAA6B,kBAAkB,OAAO,MAAM,KAAK,GAAG;CAGhF,IAAI,OAAO,MAAM,UAAU,YAAY,CAAC,UAAU,MAAM,OAAO,SAAS,KAAK,GAC3E,MAAM,IAAI,6BAA6B,gBAAgB;CAQzD,MAAM,OAAO,MAAM,oBAAoB,OAAO,MALxB,SAAS,eAAe;EAC5C;EACA,UAAU;GAAE,OAAO,SAAS;GAAO,OAAO,SAAS;GAAO,cAAc,SAAS;EAAa;CAChG,CAAC,CAEoD;CAErD,OAAO,YAAY,cAAc,MAAM,UAAU;AACnD"}
1
+ {"version":3,"file":"provider-login.mjs","names":[],"sources":["../../../../../../../auth/src/providers/provider-login.ts"],"sourcesContent":["import type { ChildModel } from \"@warlock.js/cascade\";\nimport type { Request, Response } from \"@warlock.js/core\";\nimport type { AuthProvider } from \"../contracts/providers\";\nimport type { DeviceInfo, LoginResult } from \"../contracts/types\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport type { Auth } from \"../models/auth.model\";\nimport { authConfig } from \"../services/auth-config\";\nimport { authService } from \"../services/auth.service\";\nimport { AppleProvider } from \"./apple-provider\";\nimport { DiscordProvider } from \"./discord-provider\";\nimport { FacebookProvider } from \"./facebook-provider\";\nimport { GitHubProvider } from \"./github-provider\";\nimport { GoogleProvider } from \"./google-provider\";\nimport { LinkedInProvider } from \"./linkedin-provider\";\nimport { XProvider } from \"./x-provider\";\nimport { safeEqual } from \"../utils/safe-equal\";\nimport { randomUrlSafe, takeProviderState, writeProviderState } from \"./provider-state-cookie\";\nimport { resolveProviderUser } from \"./resolve-provider-user\";\n\n/** Providers auth ships, by name — each built from `auth.providers.<name>`. */\nconst BUILT_IN_PROVIDERS: Record<string, (name: string) => AuthProvider | undefined> = {\n google: () => {\n const google = authConfig.providers.google();\n\n return google ? new GoogleProvider(google) : undefined;\n },\n github: () => {\n const github = authConfig.providers.github();\n\n return github ? new GitHubProvider(github) : undefined;\n },\n discord: () => {\n const discord = authConfig.providers.discord();\n\n return discord ? new DiscordProvider(discord) : undefined;\n },\n linkedin: () => {\n const linkedin = authConfig.providers.linkedin();\n\n return linkedin ? new LinkedInProvider(linkedin) : undefined;\n },\n apple: () => {\n const apple = authConfig.providers.apple();\n\n return apple ? new AppleProvider(apple) : undefined;\n },\n facebook: () => {\n const facebook = authConfig.providers.facebook();\n\n return facebook ? new FacebookProvider(facebook) : undefined;\n },\n x: () => {\n const x = authConfig.providers.x();\n\n return x ? new XProvider(x) : undefined;\n },\n};\n\n/**\n * Resolve a provider by name: a built-in name (`google`, `github`, `discord`,\n * `linkedin`, `apple`, `facebook`, `x`) from `auth.providers.<name>`, anything\n * else from `auth.providers.custom`. Throws naming the config key when absent.\n */\nexport function resolveAuthProvider(name: string): AuthProvider {\n const isBuiltIn = Object.hasOwn(BUILT_IN_PROVIDERS, name);\n\n if (isBuiltIn) {\n const provider = BUILT_IN_PROVIDERS[name]!(name);\n\n if (provider) return provider;\n } else {\n const custom = authConfig.providers.custom(name);\n\n if (custom) return custom;\n }\n\n throw new Error(\n `@warlock.js/auth: no login provider \"${name}\" — configure \\`auth.providers.${\n isBuiltIn ? name : `custom.${name}`\n }\\`.`,\n );\n}\n\n/**\n * Start a redirect login: generate `state`, `nonce` and a PKCE verifier, store\n * them in a signed 10-minute cookie, and resolve the provider URL to redirect\n * the browser to.\n *\n * @example\n * // GET /auth/google\n * export const googleRedirect: RequestHandler = async (_request, response) =>\n * response.redirect(await startProviderLogin(response, \"google\"));\n */\nexport async function startProviderLogin(\n response: Response,\n providerName: string,\n): Promise<string> {\n const provider = resolveAuthProvider(providerName);\n\n const state = {\n state: randomUrlSafe(),\n nonce: randomUrlSafe(),\n codeVerifier: randomUrlSafe(),\n };\n\n const url = await provider.authorizationUrl(state);\n\n writeProviderState(response, provider.name, state, provider.callbackMode ?? \"query\");\n\n return url;\n}\n\n/**\n * Finish a redirect login on the provider's callback: consume the state\n * cookie, reject a missing/forged/expired cookie, a cookie from another\n * provider, a provider-reported error or a `state` mismatch; let the provider\n * verify the callback; find-or-link the user; then log them in through\n * `authService.completeLogin` — the same outcome as password login.\n *\n * @throws InvalidProviderCallbackError (400, EC009) for any callback rejection.\n * @throws ProviderEmailNotVerifiedError (403, EC010) for an unlinked, unverified email.\n * @throws AuthProviderSdkMissingError when the provider's SDK is not installed.\n *\n * @example\n * // GET /auth/google/callback\n * const { user, tokens } = await completeProviderLogin(User, \"google\", request, response);\n * authService.setAuthCookie(response, tokens.accessToken);\n */\nexport async function completeProviderLogin<T extends Auth>(\n Model: ChildModel<T>,\n providerName: string,\n request: Request,\n response: Response,\n deviceInfo?: DeviceInfo,\n): Promise<LoginResult<T>> {\n const provider = resolveAuthProvider(providerName);\n const expected = takeProviderState(request, response);\n\n if (!expected) {\n throw new InvalidProviderCallbackError(\"missing-or-invalid-state-cookie\");\n }\n\n if (expected.provider !== provider.name) {\n throw new InvalidProviderCallbackError(\"state-cookie-for-another-provider\");\n }\n\n const query: Record<string, unknown> = {\n code: request.input(\"code\"),\n state: request.input(\"state\"),\n error: request.input(\"error\"),\n // Apple's form_post callback carries the account name here, on the first authorization only.\n user: request.input(\"user\"),\n };\n\n if (query.error) {\n throw new InvalidProviderCallbackError(`provider-error:${String(query.error)}`);\n }\n\n if (typeof query.state !== \"string\" || !safeEqual(query.state, expected.state)) {\n throw new InvalidProviderCallbackError(\"state-mismatch\");\n }\n\n const profile = await provider.handleCallback({\n query,\n expected: { state: expected.state, nonce: expected.nonce, codeVerifier: expected.codeVerifier },\n });\n\n const user = await resolveProviderUser(Model, profile);\n\n return authService.completeLogin(user, deviceInfo);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,MAAM,qBAAiF;CACrF,cAAc;EACZ,MAAM,SAAS,WAAW,UAAU,OAAO;EAE3C,OAAO,SAAS,IAAI,eAAe,MAAM,IAAI;CAC/C;CACA,cAAc;EACZ,MAAM,SAAS,WAAW,UAAU,OAAO;EAE3C,OAAO,SAAS,IAAI,eAAe,MAAM,IAAI;CAC/C;CACA,eAAe;EACb,MAAM,UAAU,WAAW,UAAU,QAAQ;EAE7C,OAAO,UAAU,IAAI,gBAAgB,OAAO,IAAI;CAClD;CACA,gBAAgB;EACd,MAAM,WAAW,WAAW,UAAU,SAAS;EAE/C,OAAO,WAAW,IAAI,iBAAiB,QAAQ,IAAI;CACrD;CACA,aAAa;EACX,MAAM,QAAQ,WAAW,UAAU,MAAM;EAEzC,OAAO,QAAQ,IAAI,cAAc,KAAK,IAAI;CAC5C;CACA,gBAAgB;EACd,MAAM,WAAW,WAAW,UAAU,SAAS;EAE/C,OAAO,WAAW,IAAI,iBAAiB,QAAQ,IAAI;CACrD;CACA,SAAS;EACP,MAAM,IAAI,WAAW,UAAU,EAAE;EAEjC,OAAO,IAAI,IAAI,UAAU,CAAC,IAAI;CAChC;AACF;;;;;;AAOA,SAAgB,oBAAoB,MAA4B;CAC9D,MAAM,YAAY,OAAO,OAAO,oBAAoB,IAAI;CAExD,IAAI,WAAW;EACb,MAAM,WAAW,mBAAmB,KAAK,CAAE,IAAI;EAE/C,IAAI,UAAU,OAAO;CACvB,OAAO;EACL,MAAM,SAAS,WAAW,UAAU,OAAO,IAAI;EAE/C,IAAI,QAAQ,OAAO;CACrB;CAEA,MAAM,IAAI,MACR,wCAAwC,KAAK,iCAC3C,YAAY,OAAO,UAAU,OAC9B,IACH;AACF;;;;;;;;;;;AAYA,eAAsB,mBACpB,UACA,cACiB;CACjB,MAAM,WAAW,oBAAoB,YAAY;CAEjD,MAAM,QAAQ;EACZ,OAAO,cAAc;EACrB,OAAO,cAAc;EACrB,cAAc,cAAc;CAC9B;CAEA,MAAM,MAAM,MAAM,SAAS,iBAAiB,KAAK;CAEjD,mBAAmB,UAAU,SAAS,MAAM,OAAO,SAAS,gBAAgB,OAAO;CAEnF,OAAO;AACT;;;;;;;;;;;;;;;;;AAkBA,eAAsB,sBACpB,OACA,cACA,SACA,UACA,YACyB;CACzB,MAAM,WAAW,oBAAoB,YAAY;CACjD,MAAM,WAAW,kBAAkB,SAAS,QAAQ;CAEpD,IAAI,CAAC,UACH,MAAM,IAAI,6BAA6B,iCAAiC;CAG1E,IAAI,SAAS,aAAa,SAAS,MACjC,MAAM,IAAI,6BAA6B,mCAAmC;CAG5E,MAAM,QAAiC;EACrC,MAAM,QAAQ,MAAM,MAAM;EAC1B,OAAO,QAAQ,MAAM,OAAO;EAC5B,OAAO,QAAQ,MAAM,OAAO;EAE5B,MAAM,QAAQ,MAAM,MAAM;CAC5B;CAEA,IAAI,MAAM,OACR,MAAM,IAAI,6BAA6B,kBAAkB,OAAO,MAAM,KAAK,GAAG;CAGhF,IAAI,OAAO,MAAM,UAAU,YAAY,CAAC,UAAU,MAAM,OAAO,SAAS,KAAK,GAC3E,MAAM,IAAI,6BAA6B,gBAAgB;CAQzD,MAAM,OAAO,MAAM,oBAAoB,OAAO,MALxB,SAAS,eAAe;EAC5C;EACA,UAAU;GAAE,OAAO,SAAS;GAAO,OAAO,SAAS;GAAO,cAAc,SAAS;EAAa;CAChG,CAAC,CAEoD;CAErD,OAAO,YAAY,cAAc,MAAM,UAAU;AACnD"}
@@ -19,41 +19,60 @@ function sign(payload) {
19
19
  return createHmac("sha256", createHmac("sha256", authConfig.accessToken.secret()).update("@warlock.js/auth:provider-state").digest()).update(payload).digest("base64url");
20
20
  }
21
21
  /**
22
- * Write the signed, 10-minute state cookie for a provider login. Attribute
23
- * flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come from the
24
- * `response.cookie()` secure defaults; `Lax` still rides the provider's
25
- * top-level GET redirect back to the app.
22
+ * Write the signed, 10-minute state cookie for a provider login. `HttpOnly`
23
+ * comes from the `response.cookie()` secure defaults. `callbackMode` picks
24
+ * the `SameSite`/`Secure` pair: `"query"` (default) keeps the framework's
25
+ * `SameSite=Lax`, which still rides a top-level GET redirect back to the app;
26
+ * `"form_post"` (Apple) overrides to `SameSite=None; Secure`, since a browser
27
+ * drops a `Lax` cookie on the provider's cross-site POST callback. Either
28
+ * way, the signed `state`/`nonce`/PKCE values — not the cookie's SameSite
29
+ * attribute — are what actually defend against CSRF here.
26
30
  */
27
- function writeProviderState(response, provider, state) {
31
+ function writeProviderState(response, provider, state, callbackMode = "query") {
28
32
  const stored = {
29
33
  ...state,
30
34
  provider,
31
- expiresAt: Date.now() + PROVIDER_STATE_TTL_SECONDS * 1e3
35
+ expiresAt: Date.now() + PROVIDER_STATE_TTL_SECONDS * 1e3,
36
+ callbackMode
32
37
  };
33
38
  const payload = Buffer.from(JSON.stringify(stored)).toString("base64url");
34
39
  response.cookie(PROVIDER_STATE_COOKIE, `${payload}.${sign(payload)}`, {
35
40
  raw: true,
36
41
  path: "/",
37
- maxAge: PROVIDER_STATE_TTL_SECONDS
42
+ maxAge: PROVIDER_STATE_TTL_SECONDS,
43
+ ...callbackMode === "form_post" ? {
44
+ sameSite: "none",
45
+ secure: true
46
+ } : {}
38
47
  });
39
48
  }
40
49
  /**
41
50
  * Read AND clear the state cookie, so a started login completes at most once.
42
51
  * Resolves `undefined` for an absent, forged (bad MAC), malformed or expired
43
- * cookie.
52
+ * cookie. The clearing `Set-Cookie` repeats whatever `SameSite`/`Secure` pair
53
+ * the cookie was WRITTEN with (recovered from the decoded payload itself, so
54
+ * this needs no mode passed in) — a browser only honours a clearing cookie
55
+ * when it matches the original's attributes, and for a `form_post` (Apple)
56
+ * cookie that means `SameSite=None; Secure` too, or the original survives.
44
57
  */
45
58
  function takeProviderState(request, response) {
46
59
  const raw = request.cookie(PROVIDER_STATE_COOKIE);
47
- response.clearCookie(PROVIDER_STATE_COOKIE, { path: "/" });
48
- if (typeof raw !== "string") return void 0;
49
- const [payload, signature, ...rest] = raw.split(".");
50
- if (!payload || !signature || rest.length > 0 || !safeEqual(signature, sign(payload))) return;
51
60
  let stored;
52
- try {
53
- stored = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
54
- } catch {
55
- return;
61
+ if (typeof raw === "string") {
62
+ const [payload, signature, ...rest] = raw.split(".");
63
+ if (payload && signature && rest.length === 0 && safeEqual(signature, sign(payload))) try {
64
+ stored = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
65
+ } catch {
66
+ stored = void 0;
67
+ }
56
68
  }
69
+ response.clearCookie(PROVIDER_STATE_COOKIE, {
70
+ path: "/",
71
+ ...stored?.callbackMode === "form_post" ? {
72
+ sameSite: "none",
73
+ secure: true
74
+ } : {}
75
+ });
57
76
  if (typeof stored?.state !== "string" || typeof stored.nonce !== "string" || typeof stored.codeVerifier !== "string" || typeof stored.provider !== "string" || typeof stored.expiresAt !== "number" || stored.expiresAt <= Date.now()) return;
58
77
  return stored;
59
78
  }
@@ -1 +1 @@
1
- {"version":3,"file":"provider-state-cookie.mjs","names":[],"sources":["../../../../../../../auth/src/providers/provider-state-cookie.ts"],"sourcesContent":["import type { Request, Response } from \"@warlock.js/core\";\nimport { createHmac, randomBytes } from \"node:crypto\";\nimport type { ProviderAuthorizationState } from \"../contracts/providers\";\nimport { authConfig } from \"../services/auth-config\";\nimport { safeEqual } from \"../utils/safe-equal\";\n\n/** Cookie carrying a redirect login's state from the start to the callback. */\nexport const PROVIDER_STATE_COOKIE = \"auth_provider_state\";\n\n/** How long a started provider login stays completable. */\nconst PROVIDER_STATE_TTL_SECONDS = 600;\n\n/** What the signed cookie holds. */\nexport type StoredProviderState = ProviderAuthorizationState & {\n provider: string;\n expiresAt: number;\n};\n\n/** 32 random bytes, base64url — used for `state`, `nonce` and the PKCE verifier. */\nexport function randomUrlSafe(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\n/**\n * HMAC over the payload with a key DERIVED from the access-token secret, so\n * the state cookie never shares a MAC key with the JWTs themselves.\n */\nfunction sign(payload: string): string {\n const key = createHmac(\"sha256\", authConfig.accessToken.secret())\n .update(\"@warlock.js/auth:provider-state\")\n .digest();\n\n return createHmac(\"sha256\", key).update(payload).digest(\"base64url\");\n}\n\n/**\n * Write the signed, 10-minute state cookie for a provider login. Attribute\n * flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come from the\n * `response.cookie()` secure defaults; `Lax` still rides the provider's\n * top-level GET redirect back to the app.\n */\nexport function writeProviderState(\n response: Response,\n provider: string,\n state: ProviderAuthorizationState,\n): void {\n const stored: StoredProviderState = {\n ...state,\n provider,\n expiresAt: Date.now() + PROVIDER_STATE_TTL_SECONDS * 1000,\n };\n const payload = Buffer.from(JSON.stringify(stored)).toString(\"base64url\");\n\n response.cookie(PROVIDER_STATE_COOKIE, `${payload}.${sign(payload)}`, {\n raw: true,\n path: \"/\",\n maxAge: PROVIDER_STATE_TTL_SECONDS,\n });\n}\n\n/**\n * Read AND clear the state cookie, so a started login completes at most once.\n * Resolves `undefined` for an absent, forged (bad MAC), malformed or expired\n * cookie.\n */\nexport function takeProviderState(\n request: Request,\n response: Response,\n): StoredProviderState | undefined {\n const raw: unknown = request.cookie(PROVIDER_STATE_COOKIE);\n\n response.clearCookie(PROVIDER_STATE_COOKIE, { path: \"/\" });\n\n if (typeof raw !== \"string\") return undefined;\n\n const [payload, signature, ...rest] = raw.split(\".\");\n\n if (!payload || !signature || rest.length > 0 || !safeEqual(signature, sign(payload))) {\n return undefined;\n }\n\n let stored: Partial<StoredProviderState> | undefined;\n\n try {\n stored = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n } catch {\n return undefined;\n }\n\n if (\n typeof stored?.state !== \"string\" ||\n typeof stored.nonce !== \"string\" ||\n typeof stored.codeVerifier !== \"string\" ||\n typeof stored.provider !== \"string\" ||\n typeof stored.expiresAt !== \"number\" ||\n stored.expiresAt <= Date.now()\n ) {\n return undefined;\n }\n\n return stored as StoredProviderState;\n}\n"],"mappings":";;;;;;AAOA,MAAa,wBAAwB;;AAGrC,MAAM,6BAA6B;;AASnC,SAAgB,gBAAwB;CACtC,OAAO,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;AAC7C;;;;;AAMA,SAAS,KAAK,SAAyB;CAKrC,OAAO,WAAW,UAJN,WAAW,UAAU,WAAW,YAAY,OAAO,CAAC,CAAC,CAC9D,OAAO,iCAAiC,CAAC,CACzC,OAE2B,CAAC,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,OAAO,WAAW;AACrE;;;;;;;AAQA,SAAgB,mBACd,UACA,UACA,OACM;CACN,MAAM,SAA8B;EAClC,GAAG;EACH;EACA,WAAW,KAAK,IAAI,IAAI,6BAA6B;CACvD;CACA,MAAM,UAAU,OAAO,KAAK,KAAK,UAAU,MAAM,CAAC,CAAC,CAAC,SAAS,WAAW;CAExE,SAAS,OAAO,uBAAuB,GAAG,QAAQ,GAAG,KAAK,OAAO,KAAK;EACpE,KAAK;EACL,MAAM;EACN,QAAQ;CACV,CAAC;AACH;;;;;;AAOA,SAAgB,kBACd,SACA,UACiC;CACjC,MAAM,MAAe,QAAQ,OAAO,qBAAqB;CAEzD,SAAS,YAAY,uBAAuB,EAAE,MAAM,IAAI,CAAC;CAEzD,IAAI,OAAO,QAAQ,UAAU,OAAO;CAEpC,MAAM,CAAC,SAAS,WAAW,GAAG,QAAQ,IAAI,MAAM,GAAG;CAEnD,IAAI,CAAC,WAAW,CAAC,aAAa,KAAK,SAAS,KAAK,CAAC,UAAU,WAAW,KAAK,OAAO,CAAC,GAClF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,OAAO,KAAK,SAAS,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC;CACxE,QAAQ;EACN;CACF;CAEA,IACE,OAAO,QAAQ,UAAU,YACzB,OAAO,OAAO,UAAU,YACxB,OAAO,OAAO,iBAAiB,YAC/B,OAAO,OAAO,aAAa,YAC3B,OAAO,OAAO,cAAc,YAC5B,OAAO,aAAa,KAAK,IAAI,GAE7B;CAGF,OAAO;AACT"}
1
+ {"version":3,"file":"provider-state-cookie.mjs","names":[],"sources":["../../../../../../../auth/src/providers/provider-state-cookie.ts"],"sourcesContent":["import type { Request, Response } from \"@warlock.js/core\";\nimport { createHmac, randomBytes } from \"node:crypto\";\nimport type { ProviderAuthorizationState } from \"../contracts/providers\";\nimport { authConfig } from \"../services/auth-config\";\nimport { safeEqual } from \"../utils/safe-equal\";\n\n/** Cookie carrying a redirect login's state from the start to the callback. */\nexport const PROVIDER_STATE_COOKIE = \"auth_provider_state\";\n\n/** How long a started provider login stays completable. */\nconst PROVIDER_STATE_TTL_SECONDS = 600;\n\n/** What the signed cookie holds. */\nexport type StoredProviderState = ProviderAuthorizationState & {\n provider: string;\n expiresAt: number;\n callbackMode: \"query\" | \"form_post\";\n};\n\n/** 32 random bytes, base64url — used for `state`, `nonce` and the PKCE verifier. */\nexport function randomUrlSafe(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\n/**\n * HMAC over the payload with a key DERIVED from the access-token secret, so\n * the state cookie never shares a MAC key with the JWTs themselves.\n */\nfunction sign(payload: string): string {\n const key = createHmac(\"sha256\", authConfig.accessToken.secret())\n .update(\"@warlock.js/auth:provider-state\")\n .digest();\n\n return createHmac(\"sha256\", key).update(payload).digest(\"base64url\");\n}\n\n/**\n * Write the signed, 10-minute state cookie for a provider login. `HttpOnly`\n * comes from the `response.cookie()` secure defaults. `callbackMode` picks\n * the `SameSite`/`Secure` pair: `\"query\"` (default) keeps the framework's\n * `SameSite=Lax`, which still rides a top-level GET redirect back to the app;\n * `\"form_post\"` (Apple) overrides to `SameSite=None; Secure`, since a browser\n * drops a `Lax` cookie on the provider's cross-site POST callback. Either\n * way, the signed `state`/`nonce`/PKCE values — not the cookie's SameSite\n * attribute — are what actually defend against CSRF here.\n */\nexport function writeProviderState(\n response: Response,\n provider: string,\n state: ProviderAuthorizationState,\n callbackMode: \"query\" | \"form_post\" = \"query\",\n): void {\n const stored: StoredProviderState = {\n ...state,\n provider,\n expiresAt: Date.now() + PROVIDER_STATE_TTL_SECONDS * 1000,\n callbackMode,\n };\n const payload = Buffer.from(JSON.stringify(stored)).toString(\"base64url\");\n\n response.cookie(PROVIDER_STATE_COOKIE, `${payload}.${sign(payload)}`, {\n raw: true,\n path: \"/\",\n maxAge: PROVIDER_STATE_TTL_SECONDS,\n ...(callbackMode === \"form_post\" ? { sameSite: \"none\", secure: true } : {}),\n });\n}\n\n/**\n * Read AND clear the state cookie, so a started login completes at most once.\n * Resolves `undefined` for an absent, forged (bad MAC), malformed or expired\n * cookie. The clearing `Set-Cookie` repeats whatever `SameSite`/`Secure` pair\n * the cookie was WRITTEN with (recovered from the decoded payload itself, so\n * this needs no mode passed in) — a browser only honours a clearing cookie\n * when it matches the original's attributes, and for a `form_post` (Apple)\n * cookie that means `SameSite=None; Secure` too, or the original survives.\n */\nexport function takeProviderState(\n request: Request,\n response: Response,\n): StoredProviderState | undefined {\n const raw: unknown = request.cookie(PROVIDER_STATE_COOKIE);\n\n let stored: Partial<StoredProviderState> | undefined;\n\n if (typeof raw === \"string\") {\n const [payload, signature, ...rest] = raw.split(\".\");\n\n if (payload && signature && rest.length === 0 && safeEqual(signature, sign(payload))) {\n try {\n stored = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n } catch {\n stored = undefined;\n }\n }\n }\n\n response.clearCookie(PROVIDER_STATE_COOKIE, {\n path: \"/\",\n ...(stored?.callbackMode === \"form_post\" ? { sameSite: \"none\", secure: true } : {}),\n });\n\n if (\n typeof stored?.state !== \"string\" ||\n typeof stored.nonce !== \"string\" ||\n typeof stored.codeVerifier !== \"string\" ||\n typeof stored.provider !== \"string\" ||\n typeof stored.expiresAt !== \"number\" ||\n stored.expiresAt <= Date.now()\n ) {\n return undefined;\n }\n\n return stored as StoredProviderState;\n}\n"],"mappings":";;;;;;AAOA,MAAa,wBAAwB;;AAGrC,MAAM,6BAA6B;;AAUnC,SAAgB,gBAAwB;CACtC,OAAO,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;AAC7C;;;;;AAMA,SAAS,KAAK,SAAyB;CAKrC,OAAO,WAAW,UAJN,WAAW,UAAU,WAAW,YAAY,OAAO,CAAC,CAAC,CAC9D,OAAO,iCAAiC,CAAC,CACzC,OAE2B,CAAC,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,OAAO,WAAW;AACrE;;;;;;;;;;;AAYA,SAAgB,mBACd,UACA,UACA,OACA,eAAsC,SAChC;CACN,MAAM,SAA8B;EAClC,GAAG;EACH;EACA,WAAW,KAAK,IAAI,IAAI,6BAA6B;EACrD;CACF;CACA,MAAM,UAAU,OAAO,KAAK,KAAK,UAAU,MAAM,CAAC,CAAC,CAAC,SAAS,WAAW;CAExE,SAAS,OAAO,uBAAuB,GAAG,QAAQ,GAAG,KAAK,OAAO,KAAK;EACpE,KAAK;EACL,MAAM;EACN,QAAQ;EACR,GAAI,iBAAiB,cAAc;GAAE,UAAU;GAAQ,QAAQ;EAAK,IAAI,CAAC;CAC3E,CAAC;AACH;;;;;;;;;;AAWA,SAAgB,kBACd,SACA,UACiC;CACjC,MAAM,MAAe,QAAQ,OAAO,qBAAqB;CAEzD,IAAI;CAEJ,IAAI,OAAO,QAAQ,UAAU;EAC3B,MAAM,CAAC,SAAS,WAAW,GAAG,QAAQ,IAAI,MAAM,GAAG;EAEnD,IAAI,WAAW,aAAa,KAAK,WAAW,KAAK,UAAU,WAAW,KAAK,OAAO,CAAC,GACjF,IAAI;GACF,SAAS,KAAK,MAAM,OAAO,KAAK,SAAS,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC;EACxE,QAAQ;GACN,SAAS;EACX;CAEJ;CAEA,SAAS,YAAY,uBAAuB;EAC1C,MAAM;EACN,GAAI,QAAQ,iBAAiB,cAAc;GAAE,UAAU;GAAQ,QAAQ;EAAK,IAAI,CAAC;CACnF,CAAC;CAED,IACE,OAAO,QAAQ,UAAU,YACzB,OAAO,OAAO,UAAU,YACxB,OAAO,OAAO,iBAAiB,YAC/B,OAAO,OAAO,aAAa,YAC3B,OAAO,OAAO,cAAc,YAC5B,OAAO,aAAa,KAAK,IAAI,GAE7B;CAGF,OAAO;AACT"}
@@ -0,0 +1,35 @@
1
+ import { AuthProvider, ProviderAuthorizationState, ProviderCallbackParams, ProviderProfile, XProviderConfig } from "../contracts/providers.mjs";
2
+
3
+ //#region ../auth/src/providers/x-provider.d.ts
4
+ /**
5
+ * X (Twitter) sign-in: OAuth 2.0 authorization code flow with PKCE (S256). X
6
+ * is not OpenID Connect — there is no `id_token`. X's confidential clients
7
+ * authenticate the token request with HTTP Basic auth (`client_id:client_secret`),
8
+ * on top of PKCE — both are required together, per X's developer docs (this
9
+ * package has no X test app, so the exchange itself is unverified against a
10
+ * live endpoint). The access token is then redeemed once against
11
+ * `GET /2/users/me`.
12
+ *
13
+ * X does not return an email address from this API at all — there is no
14
+ * "email" scope to request it with — so `email` is always left `undefined`
15
+ * rather than invented, and `resolveProviderUser` rejects it the same way it
16
+ * rejects any other missing email.
17
+ */
18
+ declare class XProvider implements AuthProvider {
19
+ private readonly settings;
20
+ readonly name = "x";
21
+ constructor(settings: XProviderConfig);
22
+ authorizationUrl(state: ProviderAuthorizationState): string;
23
+ handleCallback({
24
+ query,
25
+ expected
26
+ }: ProviderCallbackParams): Promise<ProviderProfile>;
27
+ /** Basic-auth credential for the confidential-client token request. */
28
+ private basicAuth;
29
+ /** Redeem the authorization code (with the PKCE verifier) for an access token. */
30
+ private exchangeCode;
31
+ private fetchUser;
32
+ }
33
+ //#endregion
34
+ export { XProvider };
35
+ //# sourceMappingURL=x-provider.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"x-provider.d.mts","names":[],"sources":["../../../../../../../auth/src/providers/x-provider.ts"],"mappings":";;;;;AAoCA;;;;;;;;;;;;cAAa,SAAA,YAAqB,YAAA;EAAA,iBAGI,QAAA;EAAA,SAFpB,IAAA;cAEoB,QAAA,EAAU,eAAA;EAEvC,gBAAA,CAAiB,KAAA,EAAO,0BAAA;EAclB,cAAA;IACX,KAAA;IACA;EAAA,GACC,sBAAA,GAAyB,OAAA,CAAQ,eAAA;EAnBA;EAAA,QA8C5B,SAAA;EA5CuB;EAAA,QAmDjB,YAAA;EAAA,QAuCA,SAAA;AAAA"}
@@ -0,0 +1,101 @@
1
+ import { InvalidProviderCallbackError } from "../errors/invalid-provider-callback.error.mjs";
2
+ import { pkceChallenge } from "./google-provider.mjs";
3
+
4
+ //#region ../auth/src/providers/x-provider.ts
5
+ const AUTHORIZATION_ENDPOINT = "https://twitter.com/i/oauth2/authorize";
6
+ const TOKEN_ENDPOINT = "https://api.twitter.com/2/oauth2/token";
7
+ const USER_ENDPOINT = "https://api.twitter.com/2/users/me";
8
+ const DEFAULT_SCOPES = ["tweet.read", "users.read"];
9
+ /**
10
+ * X (Twitter) sign-in: OAuth 2.0 authorization code flow with PKCE (S256). X
11
+ * is not OpenID Connect — there is no `id_token`. X's confidential clients
12
+ * authenticate the token request with HTTP Basic auth (`client_id:client_secret`),
13
+ * on top of PKCE — both are required together, per X's developer docs (this
14
+ * package has no X test app, so the exchange itself is unverified against a
15
+ * live endpoint). The access token is then redeemed once against
16
+ * `GET /2/users/me`.
17
+ *
18
+ * X does not return an email address from this API at all — there is no
19
+ * "email" scope to request it with — so `email` is always left `undefined`
20
+ * rather than invented, and `resolveProviderUser` rejects it the same way it
21
+ * rejects any other missing email.
22
+ */
23
+ var XProvider = class {
24
+ constructor(settings) {
25
+ this.settings = settings;
26
+ this.name = "x";
27
+ }
28
+ authorizationUrl(state) {
29
+ const url = new URL(AUTHORIZATION_ENDPOINT);
30
+ url.searchParams.set("client_id", this.settings.clientId);
31
+ url.searchParams.set("redirect_uri", this.settings.redirectUri);
32
+ url.searchParams.set("response_type", "code");
33
+ url.searchParams.set("scope", (this.settings.scopes ?? DEFAULT_SCOPES).join(" "));
34
+ url.searchParams.set("state", state.state);
35
+ url.searchParams.set("code_challenge", pkceChallenge(state.codeVerifier));
36
+ url.searchParams.set("code_challenge_method", "S256");
37
+ return url.toString();
38
+ }
39
+ async handleCallback({ query, expected }) {
40
+ const code = query.code;
41
+ if (typeof code !== "string" || code.length === 0) throw new InvalidProviderCallbackError("missing-code");
42
+ const accessToken = await this.exchangeCode(code, expected.codeVerifier);
43
+ const user = await this.fetchUser(accessToken);
44
+ if (typeof user.id !== "string" || user.id.length === 0) throw new InvalidProviderCallbackError("missing-subject");
45
+ return {
46
+ provider: this.name,
47
+ providerUserId: user.id,
48
+ email: void 0,
49
+ emailVerified: false,
50
+ name: user.name ?? user.username,
51
+ avatar: user.profile_image_url,
52
+ raw: user
53
+ };
54
+ }
55
+ /** Basic-auth credential for the confidential-client token request. */
56
+ basicAuth() {
57
+ return Buffer.from(`${this.settings.clientId}:${this.settings.clientSecret}`).toString("base64");
58
+ }
59
+ /** Redeem the authorization code (with the PKCE verifier) for an access token. */
60
+ async exchangeCode(code, codeVerifier) {
61
+ let body;
62
+ try {
63
+ const response = await fetch(TOKEN_ENDPOINT, {
64
+ method: "POST",
65
+ headers: {
66
+ "content-type": "application/x-www-form-urlencoded",
67
+ authorization: `Basic ${this.basicAuth()}`
68
+ },
69
+ body: new URLSearchParams({
70
+ code,
71
+ client_id: this.settings.clientId,
72
+ redirect_uri: this.settings.redirectUri,
73
+ grant_type: "authorization_code",
74
+ code_verifier: codeVerifier
75
+ })
76
+ });
77
+ if (!response.ok) throw new Error(`X token endpoint answered ${response.status}`);
78
+ body = await response.json();
79
+ } catch (error) {
80
+ throw new InvalidProviderCallbackError("code-exchange-failed", error);
81
+ }
82
+ if (body.error) throw new InvalidProviderCallbackError("code-exchange-failed", body.error);
83
+ if (typeof body.access_token !== "string" || body.access_token.length === 0) throw new InvalidProviderCallbackError("missing-access-token");
84
+ return body.access_token;
85
+ }
86
+ async fetchUser(accessToken) {
87
+ try {
88
+ const url = new URL(USER_ENDPOINT);
89
+ url.searchParams.set("user.fields", "profile_image_url");
90
+ const response = await fetch(url.toString(), { headers: { authorization: `Bearer ${accessToken}` } });
91
+ if (!response.ok) throw new Error(`X /2/users/me endpoint answered ${response.status}`);
92
+ return (await response.json()).data ?? {};
93
+ } catch (error) {
94
+ throw new InvalidProviderCallbackError("profile-fetch-failed", error);
95
+ }
96
+ }
97
+ };
98
+
99
+ //#endregion
100
+ export { XProvider };
101
+ //# sourceMappingURL=x-provider.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"x-provider.mjs","names":[],"sources":["../../../../../../../auth/src/providers/x-provider.ts"],"sourcesContent":["import type {\n AuthProvider,\n ProviderAuthorizationState,\n ProviderCallbackParams,\n ProviderProfile,\n XProviderConfig,\n} from \"../contracts/providers\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport { pkceChallenge } from \"./google-provider\";\n\nconst AUTHORIZATION_ENDPOINT = \"https://twitter.com/i/oauth2/authorize\";\nconst TOKEN_ENDPOINT = \"https://api.twitter.com/2/oauth2/token\";\nconst USER_ENDPOINT = \"https://api.twitter.com/2/users/me\";\nconst DEFAULT_SCOPES = [\"tweet.read\", \"users.read\"];\n\ntype XUser = {\n id: string;\n name?: string;\n username?: string;\n profile_image_url?: string;\n};\n\n/**\n * X (Twitter) sign-in: OAuth 2.0 authorization code flow with PKCE (S256). X\n * is not OpenID Connect — there is no `id_token`. X's confidential clients\n * authenticate the token request with HTTP Basic auth (`client_id:client_secret`),\n * on top of PKCE — both are required together, per X's developer docs (this\n * package has no X test app, so the exchange itself is unverified against a\n * live endpoint). The access token is then redeemed once against\n * `GET /2/users/me`.\n *\n * X does not return an email address from this API at all — there is no\n * \"email\" scope to request it with — so `email` is always left `undefined`\n * rather than invented, and `resolveProviderUser` rejects it the same way it\n * rejects any other missing email.\n */\nexport class XProvider implements AuthProvider {\n public readonly name = \"x\";\n\n public constructor(private readonly settings: XProviderConfig) {}\n\n public authorizationUrl(state: ProviderAuthorizationState): string {\n const url = new URL(AUTHORIZATION_ENDPOINT);\n\n url.searchParams.set(\"client_id\", this.settings.clientId);\n url.searchParams.set(\"redirect_uri\", this.settings.redirectUri);\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"scope\", (this.settings.scopes ?? DEFAULT_SCOPES).join(\" \"));\n url.searchParams.set(\"state\", state.state);\n url.searchParams.set(\"code_challenge\", pkceChallenge(state.codeVerifier));\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n\n return url.toString();\n }\n\n public async handleCallback({\n query,\n expected,\n }: ProviderCallbackParams): Promise<ProviderProfile> {\n const code = query.code;\n\n if (typeof code !== \"string\" || code.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-code\");\n }\n\n const accessToken = await this.exchangeCode(code, expected.codeVerifier);\n const user = await this.fetchUser(accessToken);\n\n if (typeof user.id !== \"string\" || user.id.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-subject\");\n }\n\n return {\n provider: this.name,\n providerUserId: user.id,\n // X does not hand back an email address from this API.\n email: undefined,\n emailVerified: false,\n name: user.name ?? user.username,\n avatar: user.profile_image_url,\n raw: user,\n };\n }\n\n /** Basic-auth credential for the confidential-client token request. */\n private basicAuth(): string {\n return Buffer.from(`${this.settings.clientId}:${this.settings.clientSecret}`).toString(\n \"base64\",\n );\n }\n\n /** Redeem the authorization code (with the PKCE verifier) for an access token. */\n private async exchangeCode(code: string, codeVerifier: string): Promise<string> {\n let body: { access_token?: unknown; error?: unknown };\n\n try {\n const response = await fetch(TOKEN_ENDPOINT, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-www-form-urlencoded\",\n authorization: `Basic ${this.basicAuth()}`,\n },\n body: new URLSearchParams({\n code,\n client_id: this.settings.clientId,\n redirect_uri: this.settings.redirectUri,\n grant_type: \"authorization_code\",\n code_verifier: codeVerifier,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`X token endpoint answered ${response.status}`);\n }\n\n body = (await response.json()) as { access_token?: unknown; error?: unknown };\n } catch (error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", error);\n }\n\n if (body.error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", body.error);\n }\n\n if (typeof body.access_token !== \"string\" || body.access_token.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-access-token\");\n }\n\n return body.access_token;\n }\n\n private async fetchUser(accessToken: string): Promise<XUser> {\n try {\n const url = new URL(USER_ENDPOINT);\n\n url.searchParams.set(\"user.fields\", \"profile_image_url\");\n\n const response = await fetch(url.toString(), {\n headers: { authorization: `Bearer ${accessToken}` },\n });\n\n if (!response.ok) {\n throw new Error(`X /2/users/me endpoint answered ${response.status}`);\n }\n\n const body = (await response.json()) as { data?: XUser };\n\n return body.data ?? ({} as XUser);\n } catch (error) {\n throw new InvalidProviderCallbackError(\"profile-fetch-failed\", error);\n }\n }\n}\n"],"mappings":";;;;AAUA,MAAM,yBAAyB;AAC/B,MAAM,iBAAiB;AACvB,MAAM,gBAAgB;AACtB,MAAM,iBAAiB,CAAC,cAAc,YAAY;;;;;;;;;;;;;;;AAuBlD,IAAa,YAAb,MAA+C;CAG7C,AAAO,YAAY,AAAiB,UAA2B;EAA3B;cAFb;CAEyC;CAEhE,AAAO,iBAAiB,OAA2C;EACjE,MAAM,MAAM,IAAI,IAAI,sBAAsB;EAE1C,IAAI,aAAa,IAAI,aAAa,KAAK,SAAS,QAAQ;EACxD,IAAI,aAAa,IAAI,gBAAgB,KAAK,SAAS,WAAW;EAC9D,IAAI,aAAa,IAAI,iBAAiB,MAAM;EAC5C,IAAI,aAAa,IAAI,UAAU,KAAK,SAAS,UAAU,eAAc,CAAE,KAAK,GAAG,CAAC;EAChF,IAAI,aAAa,IAAI,SAAS,MAAM,KAAK;EACzC,IAAI,aAAa,IAAI,kBAAkB,cAAc,MAAM,YAAY,CAAC;EACxE,IAAI,aAAa,IAAI,yBAAyB,MAAM;EAEpD,OAAO,IAAI,SAAS;CACtB;CAEA,MAAa,eAAe,EAC1B,OACA,YACmD;EACnD,MAAM,OAAO,MAAM;EAEnB,IAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAC9C,MAAM,IAAI,6BAA6B,cAAc;EAGvD,MAAM,cAAc,MAAM,KAAK,aAAa,MAAM,SAAS,YAAY;EACvE,MAAM,OAAO,MAAM,KAAK,UAAU,WAAW;EAE7C,IAAI,OAAO,KAAK,OAAO,YAAY,KAAK,GAAG,WAAW,GACpD,MAAM,IAAI,6BAA6B,iBAAiB;EAG1D,OAAO;GACL,UAAU,KAAK;GACf,gBAAgB,KAAK;GAErB,OAAO;GACP,eAAe;GACf,MAAM,KAAK,QAAQ,KAAK;GACxB,QAAQ,KAAK;GACb,KAAK;EACP;CACF;;CAGA,AAAQ,YAAoB;EAC1B,OAAO,OAAO,KAAK,GAAG,KAAK,SAAS,SAAS,GAAG,KAAK,SAAS,cAAc,CAAC,CAAC,SAC5E,QACF;CACF;;CAGA,MAAc,aAAa,MAAc,cAAuC;EAC9E,IAAI;EAEJ,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,gBAAgB;IAC3C,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,eAAe,SAAS,KAAK,UAAU;IACzC;IACA,MAAM,IAAI,gBAAgB;KACxB;KACA,WAAW,KAAK,SAAS;KACzB,cAAc,KAAK,SAAS;KAC5B,YAAY;KACZ,eAAe;IACjB,CAAC;GACH,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,6BAA6B,SAAS,QAAQ;GAGhE,OAAQ,MAAM,SAAS,KAAK;EAC9B,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;EAEA,IAAI,KAAK,OACP,MAAM,IAAI,6BAA6B,wBAAwB,KAAK,KAAK;EAG3E,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,WAAW,GACxE,MAAM,IAAI,6BAA6B,sBAAsB;EAG/D,OAAO,KAAK;CACd;CAEA,MAAc,UAAU,aAAqC;EAC3D,IAAI;GACF,MAAM,MAAM,IAAI,IAAI,aAAa;GAEjC,IAAI,aAAa,IAAI,eAAe,mBAAmB;GAEvD,MAAM,WAAW,MAAM,MAAM,IAAI,SAAS,GAAG,EAC3C,SAAS,EAAE,eAAe,UAAU,cAAc,EACpD,CAAC;GAED,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,mCAAmC,SAAS,QAAQ;GAKtE,QAAO,MAFa,SAAS,KAAK,EAEvB,CAAC,QAAS,CAAC;EACxB,SAAS,OAAO;GACd,MAAM,IAAI,6BAA6B,wBAAwB,KAAK;EACtE;CACF;AACF"}