@micacms/auth-oidc 0.5.0-rc.2 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @micacms/auth-oidc
2
2
 
3
- > **Experimental.** Pre-1.0: the API can still change without notice.
3
+ > **Internal.** Published only because the Mica apps install it, and pinned
4
+ > exactly by them. It has no semver promise: any release can break its API. Use
5
+ > `@micacms/core`, `@micacms/astro` or the `@micacms/editor` CLI instead.
4
6
 
5
7
  The server-only OIDC gate for Mica: the authorization code flow with PKCE,
6
8
  sessions in an encrypted cookie, admission rules over provider claims, and the middleware
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * Mica has no accounts, so admission is configuration:
10
10
  *
11
- * MICA_ADMIT_GROUP=mica-editors a claim the provider sends
12
- * MICA_ADMIT_DOMAIN=example.com every verified address there
11
+ * MICA_ADMIT_GROUPS=mica-editors a claim the provider sends
12
+ * MICA_ADMIT_DOMAINS=example.com every verified address there
13
13
  * MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses
14
14
  * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3
15
15
  *
package/dist/admission.js CHANGED
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * Mica has no accounts, so admission is configuration:
10
10
  *
11
- * MICA_ADMIT_GROUP=mica-editors a claim the provider sends
12
- * MICA_ADMIT_DOMAIN=example.com every verified address there
11
+ * MICA_ADMIT_GROUPS=mica-editors a claim the provider sends
12
+ * MICA_ADMIT_DOMAINS=example.com every verified address there
13
13
  * MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses
14
14
  * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3
15
15
  *
@@ -1 +1 @@
1
- {"version":3,"file":"admission.js","sourceRoot":"","sources":["../src/admission.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Admission — who may edit this site.\n *\n * OpenID Connect states who a person is. It says nothing about whether that\n * person may edit this website: a provider vouches for a whole company, and a\n * perfectly valid ID token is the beginning of the question rather than the\n * answer to it.\n *\n * Mica has no accounts, so admission is configuration:\n *\n * MICA_ADMIT_GROUP=mica-editors a claim the provider sends\n * MICA_ADMIT_DOMAIN=example.com every verified address there\n * MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\n * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n *\n * The group rule is the one to grow into. Adding an editor is then a group\n * change in the identity provider — where the operator already manages\n * joiners and leavers — and Mica is not touched, not restarted, and holds no\n * copy of the list to go stale. The email list exists for a provider with no\n * groups, such as Google; the subject list for one that verifies no addresses.\n *\n * What this costs, and it is a real cost: removing somebody from a group at\n * the provider does not take effect until their next sign-in, because the\n * claims Mica checks are the ones it was given. Configuration changes *are*\n * immediate — this policy is re-read on every request — and the session\n * lifetime is the upper bound on the rest. docs/decisions.md records the trade.\n */\n\nimport type { AuthConfig } from \"./config.js\";\nimport type { SessionIdentity } from \"./session.js\";\n\nexport type AdmissionResult = { admitted: true } | { admitted: false; reason: string };\n\nexport interface AdmissionPolicy {\n /** One line for the startup log, so an operator can see what is in force. */\n readonly summary: string;\n admits(identity: SessionIdentity): AdmissionResult;\n}\n\n/**\n * The strictest rule: the subject never changes and is never reassigned. The\n * issuer is part of the key because a subject is only unique within the\n * provider that issued it.\n */\nfunction matchesSubject(\n identity: SessionIdentity,\n entries: ReadonlyArray<{ issuer: string; subject: string }>,\n): boolean {\n return entries.some(\n (entry) => entry.issuer === identity.issuer && entry.subject === identity.subject,\n );\n}\n\nfunction matchesEmail(identity: SessionIdentity, emails: readonly string[]): boolean {\n return emails.includes(identity.email.toLowerCase());\n}\n\nfunction matchesDomain(identity: SessionIdentity, domains: readonly string[]): boolean {\n const at = identity.email.lastIndexOf(\"@\");\n if (at < 0) return false;\n return domains.includes(identity.email.slice(at + 1).toLowerCase());\n}\n\n/**\n * Group names are compared case-sensitively, and a leading `/` is ignored.\n *\n * Keycloak sends group paths — `/mica-editors` — while most other providers\n * send bare names. Accepting both means an operator writes the name they see\n * in their own console, which is the only name they have.\n */\nfunction matchesGroup(identity: SessionIdentity, groups: readonly string[]): boolean {\n const held = new Set(identity.groups.map((group) => group.replace(/^\\//, \"\")));\n return groups.some((group) => held.has(group.replace(/^\\//, \"\")));\n}\n\nexport function createAdmissionPolicy(\n config: Pick<AuthConfig, \"admitGroups\" | \"admitDomains\" | \"admitEmails\" | \"admitSubjects\">,\n): AdmissionPolicy {\n const { admitGroups, admitDomains, admitEmails, admitSubjects } = config;\n\n const summary = [\n admitGroups.length > 0 ? `group=${admitGroups.join(\"|\")}` : null,\n admitDomains.length > 0 ? `domain=${admitDomains.join(\"|\")}` : null,\n admitEmails.length > 0 ? `emails=${admitEmails.length}` : null,\n admitSubjects.length > 0 ? `subjects=${admitSubjects.length}` : null,\n ]\n .filter(Boolean)\n .join(\", \");\n\n return {\n summary,\n\n admits(identity) {\n // The explicit list first: it is the escape hatch, and it has to work\n // when the group claim is missing or the address is unverified.\n if (matchesSubject(identity, admitSubjects)) return { admitted: true };\n\n if (matchesGroup(identity, admitGroups)) return { admitted: true };\n\n const byEmail = matchesEmail(identity, admitEmails);\n if (byEmail || matchesDomain(identity, admitDomains)) {\n /**\n * An email or domain rule trusts the provider to have checked the address.\n *\n * Where a provider allows self-registration, an unverified address is\n * a claim the person typed. Admitting on that would let anybody enter\n * an address they do not hold, so the rule refuses and says why — the\n * operator can turn verification on, or use a group or subject rule\n * instead.\n *\n * What remains is reassignment: an organisation that gives a former\n * editor's address to somebody new gives them this site too. Public\n * providers such as Gmail never reuse an address; the trade is\n * recorded in docs/decisions.md.\n */\n if (identity.emailVerified) return { admitted: true };\n\n return {\n admitted: false,\n reason:\n `${identity.email} is ${byEmail ? \"an admitted address\" : \"in an admitted domain\"}, ` +\n \"but the identity provider does not report the address as verified.\",\n };\n }\n\n return {\n admitted: false,\n reason: `${identity.email || identity.subject} is not admitted to this site.`,\n };\n },\n };\n}\n"]}
1
+ {"version":3,"file":"admission.js","sourceRoot":"","sources":["../src/admission.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Admission — who may edit this site.\n *\n * OpenID Connect states who a person is. It says nothing about whether that\n * person may edit this website: a provider vouches for a whole company, and a\n * perfectly valid ID token is the beginning of the question rather than the\n * answer to it.\n *\n * Mica has no accounts, so admission is configuration:\n *\n * MICA_ADMIT_GROUPS=mica-editors a claim the provider sends\n * MICA_ADMIT_DOMAINS=example.com every verified address there\n * MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\n * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n *\n * The group rule is the one to grow into. Adding an editor is then a group\n * change in the identity provider — where the operator already manages\n * joiners and leavers — and Mica is not touched, not restarted, and holds no\n * copy of the list to go stale. The email list exists for a provider with no\n * groups, such as Google; the subject list for one that verifies no addresses.\n *\n * What this costs, and it is a real cost: removing somebody from a group at\n * the provider does not take effect until their next sign-in, because the\n * claims Mica checks are the ones it was given. Configuration changes *are*\n * immediate — this policy is re-read on every request — and the session\n * lifetime is the upper bound on the rest. docs/decisions.md records the trade.\n */\n\nimport type { AuthConfig } from \"./config.js\";\nimport type { SessionIdentity } from \"./session.js\";\n\nexport type AdmissionResult = { admitted: true } | { admitted: false; reason: string };\n\nexport interface AdmissionPolicy {\n /** One line for the startup log, so an operator can see what is in force. */\n readonly summary: string;\n admits(identity: SessionIdentity): AdmissionResult;\n}\n\n/**\n * The strictest rule: the subject never changes and is never reassigned. The\n * issuer is part of the key because a subject is only unique within the\n * provider that issued it.\n */\nfunction matchesSubject(\n identity: SessionIdentity,\n entries: ReadonlyArray<{ issuer: string; subject: string }>,\n): boolean {\n return entries.some(\n (entry) => entry.issuer === identity.issuer && entry.subject === identity.subject,\n );\n}\n\nfunction matchesEmail(identity: SessionIdentity, emails: readonly string[]): boolean {\n return emails.includes(identity.email.toLowerCase());\n}\n\nfunction matchesDomain(identity: SessionIdentity, domains: readonly string[]): boolean {\n const at = identity.email.lastIndexOf(\"@\");\n if (at < 0) return false;\n return domains.includes(identity.email.slice(at + 1).toLowerCase());\n}\n\n/**\n * Group names are compared case-sensitively, and a leading `/` is ignored.\n *\n * Keycloak sends group paths — `/mica-editors` — while most other providers\n * send bare names. Accepting both means an operator writes the name they see\n * in their own console, which is the only name they have.\n */\nfunction matchesGroup(identity: SessionIdentity, groups: readonly string[]): boolean {\n const held = new Set(identity.groups.map((group) => group.replace(/^\\//, \"\")));\n return groups.some((group) => held.has(group.replace(/^\\//, \"\")));\n}\n\nexport function createAdmissionPolicy(\n config: Pick<AuthConfig, \"admitGroups\" | \"admitDomains\" | \"admitEmails\" | \"admitSubjects\">,\n): AdmissionPolicy {\n const { admitGroups, admitDomains, admitEmails, admitSubjects } = config;\n\n const summary = [\n admitGroups.length > 0 ? `group=${admitGroups.join(\"|\")}` : null,\n admitDomains.length > 0 ? `domain=${admitDomains.join(\"|\")}` : null,\n admitEmails.length > 0 ? `emails=${admitEmails.length}` : null,\n admitSubjects.length > 0 ? `subjects=${admitSubjects.length}` : null,\n ]\n .filter(Boolean)\n .join(\", \");\n\n return {\n summary,\n\n admits(identity) {\n // The explicit list first: it is the escape hatch, and it has to work\n // when the group claim is missing or the address is unverified.\n if (matchesSubject(identity, admitSubjects)) return { admitted: true };\n\n if (matchesGroup(identity, admitGroups)) return { admitted: true };\n\n const byEmail = matchesEmail(identity, admitEmails);\n if (byEmail || matchesDomain(identity, admitDomains)) {\n /**\n * An email or domain rule trusts the provider to have checked the address.\n *\n * Where a provider allows self-registration, an unverified address is\n * a claim the person typed. Admitting on that would let anybody enter\n * an address they do not hold, so the rule refuses and says why — the\n * operator can turn verification on, or use a group or subject rule\n * instead.\n *\n * What remains is reassignment: an organisation that gives a former\n * editor's address to somebody new gives them this site too. Public\n * providers such as Gmail never reuse an address; the trade is\n * recorded in docs/decisions.md.\n */\n if (identity.emailVerified) return { admitted: true };\n\n return {\n admitted: false,\n reason:\n `${identity.email} is ${byEmail ? \"an admitted address\" : \"in an admitted domain\"}, ` +\n \"but the identity provider does not report the address as verified.\",\n };\n }\n\n return {\n admitted: false,\n reason: `${identity.email || identity.subject} is not admitted to this site.`,\n };\n },\n };\n}\n"]}
package/dist/config.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare const authEnv: {
22
22
  * Which claim carries the person's groups. There is no standard one:
23
23
  * Keycloak and Entra normally send `groups`, others send `roles`, and some
24
24
  * send whatever the operator mapped. Naming it here is what keeps
25
- * `MICA_ADMIT_GROUP` working against any provider.
25
+ * `MICA_ADMIT_GROUPS` working against any provider.
26
26
  */
27
27
  MICA_OIDC_GROUPS_CLAIM: z.ZodDefault<z.ZodString>;
28
28
  /**
@@ -30,24 +30,24 @@ export declare const authEnv: {
30
30
  * whether that person gets in. At least one is required — the composition
31
31
  * refuses to start without one. Comma-separated, all four.
32
32
  */
33
- MICA_ADMIT_GROUP: z.ZodOptional<z.ZodString>;
34
- MICA_ADMIT_DOMAIN: z.ZodOptional<z.ZodString>;
33
+ MICA_ADMIT_GROUPS: z.ZodOptional<z.ZodString>;
34
+ MICA_ADMIT_DOMAINS: z.ZodOptional<z.ZodString>;
35
35
  /** Verified addresses — the list for a provider with no groups, such as Google. */
36
36
  MICA_ADMIT_EMAILS: z.ZodOptional<z.ZodString>;
37
37
  /** `issuer|subject` pairs — see admission.ts. */
38
38
  MICA_ADMIT_SUBJECTS: z.ZodOptional<z.ZodString>;
39
39
  /** Signs session cookies. Rotating it invalidates every session. */
40
- SESSION_SECRET: z.ZodOptional<z.ZodString>;
40
+ MICA_SESSION_SECRET: z.ZodOptional<z.ZodString>;
41
41
  /**
42
42
  * Deliberately short. Admission is configuration and is re-read on every
43
43
  * request; what cannot be immediate is a change *at the provider*, which
44
44
  * Mica only sees at the next sign-in. The session lifetime is therefore the
45
45
  * upper bound on how long a revoked editor keeps working.
46
46
  */
47
- SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
48
- SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
49
- LOGIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
50
- LOGIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
47
+ MICA_SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
48
+ MICA_SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
49
+ MICA_SIGNIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
50
+ MICA_SIGNIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
51
51
  };
52
52
  declare const authSchema: z.ZodObject<{
53
53
  MICA_OIDC_ISSUER: z.ZodOptional<z.ZodString>;
@@ -55,15 +55,15 @@ declare const authSchema: z.ZodObject<{
55
55
  MICA_OIDC_CLIENT_SECRET: z.ZodOptional<z.ZodString>;
56
56
  MICA_OIDC_SCOPE: z.ZodDefault<z.ZodString>;
57
57
  MICA_OIDC_GROUPS_CLAIM: z.ZodDefault<z.ZodString>;
58
- MICA_ADMIT_GROUP: z.ZodOptional<z.ZodString>;
59
- MICA_ADMIT_DOMAIN: z.ZodOptional<z.ZodString>;
58
+ MICA_ADMIT_GROUPS: z.ZodOptional<z.ZodString>;
59
+ MICA_ADMIT_DOMAINS: z.ZodOptional<z.ZodString>;
60
60
  MICA_ADMIT_EMAILS: z.ZodOptional<z.ZodString>;
61
61
  MICA_ADMIT_SUBJECTS: z.ZodOptional<z.ZodString>;
62
- SESSION_SECRET: z.ZodOptional<z.ZodString>;
63
- SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
64
- SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
65
- LOGIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
66
- LOGIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
62
+ MICA_SESSION_SECRET: z.ZodOptional<z.ZodString>;
63
+ MICA_SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
64
+ MICA_SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
65
+ MICA_SIGNIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
66
+ MICA_SIGNIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
67
67
  }, z.core.$strip>;
68
68
  export type AuthEnv = z.infer<typeof authSchema>;
69
69
  /** The derived values the gate reads, on top of the parsed environment. */
@@ -105,10 +105,10 @@ export interface AuthConfig {
105
105
  /** Resolved by the app: mandatory in production, ephemeral in development. */
106
106
  sessionSecret: string;
107
107
  cookieSecure: boolean;
108
- SESSION_TTL_HOURS: number;
109
- SESSION_IDLE_HOURS: number;
110
- LOGIN_MAX_ATTEMPTS: number;
111
- LOGIN_WINDOW_MS: number;
108
+ MICA_SESSION_TTL_HOURS: number;
109
+ MICA_SESSION_IDLE_HOURS: number;
110
+ MICA_SIGNIN_MAX_ATTEMPTS: number;
111
+ MICA_SIGNIN_WINDOW_MS: number;
112
112
  }
113
113
  /**
114
114
  * The refusals that make a deployment honest before it serves a request.
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,OAAO;IAClB;;;;OAIG;;;IAGH,iDAAiD;;;IAGjD;;;;;OAKG;;IAGH;;;;OAIG;;;IAGH,mFAAmF;;IAEnF,iDAAiD;;IAGjD,oEAAoE;;IAEpE;;;;;OAKG;;;;;CAUJ,CAAC;AAEF,QAAA,MAAM,UAAU;;;;;;;;;;;;;;;iBAAoB,CAAC;AACrC,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEjD,2EAA2E;AAC3E,wBAAgB,UAAU,CAAC,CAAC,SAAS,OAAO,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,EAC1F,GAAG,EAAE,CAAC;IAIJ;;;OAGG;;;;;;;;;;EAsBN;AAED,0DAA0D;AAC1D,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,eAAe,EAAE,MAAM,CAAC;IACxB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,8EAA8E;IAC9E,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;CACzB;AA0BD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3D,GAAG,IAAI,CA4DP"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,OAAO;IAClB;;;;OAIG;;;IAGH,iDAAiD;;;IAGjD;;;;;OAKG;;IAGH;;;;OAIG;;;IAGH,mFAAmF;;IAEnF,iDAAiD;;IAGjD,oEAAoE;;IAEpE;;;;;OAKG;;;;;CAUJ,CAAC;AAEF,QAAA,MAAM,UAAU;;;;;;;;;;;;;;;iBAAoB,CAAC;AACrC,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEjD,2EAA2E;AAC3E,wBAAgB,UAAU,CAAC,CAAC,SAAS,OAAO,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,EAC1F,GAAG,EAAE,CAAC;IAIJ;;;OAGG;;;;;;;;;;EAsBN;AAED,0DAA0D;AAC1D,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,eAAe,EAAE,MAAM,CAAC;IACxB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,8EAA8E;IAC9E,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,uBAAuB,EAAE,MAAM,CAAC;IAChC,wBAAwB,EAAE,MAAM,CAAC;IACjC,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AA0BD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3D,GAAG,IAAI,CA4DP"}
package/dist/config.js CHANGED
@@ -23,7 +23,7 @@ export const authEnv = {
23
23
  * Which claim carries the person's groups. There is no standard one:
24
24
  * Keycloak and Entra normally send `groups`, others send `roles`, and some
25
25
  * send whatever the operator mapped. Naming it here is what keeps
26
- * `MICA_ADMIT_GROUP` working against any provider.
26
+ * `MICA_ADMIT_GROUPS` working against any provider.
27
27
  */
28
28
  MICA_OIDC_GROUPS_CLAIM: z.string().default("groups"),
29
29
  /**
@@ -31,24 +31,24 @@ export const authEnv = {
31
31
  * whether that person gets in. At least one is required — the composition
32
32
  * refuses to start without one. Comma-separated, all four.
33
33
  */
34
- MICA_ADMIT_GROUP: z.string().optional(),
35
- MICA_ADMIT_DOMAIN: z.string().optional(),
34
+ MICA_ADMIT_GROUPS: z.string().optional(),
35
+ MICA_ADMIT_DOMAINS: z.string().optional(),
36
36
  /** Verified addresses — the list for a provider with no groups, such as Google. */
37
37
  MICA_ADMIT_EMAILS: z.string().optional(),
38
38
  /** `issuer|subject` pairs — see admission.ts. */
39
39
  MICA_ADMIT_SUBJECTS: z.string().optional(),
40
40
  /** Signs session cookies. Rotating it invalidates every session. */
41
- SESSION_SECRET: z.string().min(32).optional(),
41
+ MICA_SESSION_SECRET: z.string().min(32).optional(),
42
42
  /**
43
43
  * Deliberately short. Admission is configuration and is re-read on every
44
44
  * request; what cannot be immediate is a change *at the provider*, which
45
45
  * Mica only sees at the next sign-in. The session lifetime is therefore the
46
46
  * upper bound on how long a revoked editor keeps working.
47
47
  */
48
- SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),
49
- SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),
50
- LOGIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),
51
- LOGIN_WINDOW_MS: z.coerce
48
+ MICA_SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),
49
+ MICA_SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),
50
+ MICA_SIGNIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),
51
+ MICA_SIGNIN_WINDOW_MS: z.coerce
52
52
  .number()
53
53
  .int()
54
54
  .positive()
@@ -65,10 +65,10 @@ export function deriveAuth(env) {
65
65
  */
66
66
  oidcAllowInsecure: env.NODE_ENV !== "production" &&
67
67
  /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(env.MICA_OIDC_ISSUER ?? ""),
68
- admitGroups: list(env.MICA_ADMIT_GROUP),
68
+ admitGroups: list(env.MICA_ADMIT_GROUPS),
69
69
  // Lowercased: a domain is case-insensitive, and an operator who writes
70
70
  // `Example.com` means the same thing the provider spells `example.com`.
71
- admitDomains: list(env.MICA_ADMIT_DOMAIN).map((domain) => domain.toLowerCase().replace(/^@/, "")),
71
+ admitDomains: list(env.MICA_ADMIT_DOMAINS).map((domain) => domain.toLowerCase().replace(/^@/, "")),
72
72
  admitEmails: list(env.MICA_ADMIT_EMAILS).map((email) => email.toLowerCase()),
73
73
  admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {
74
74
  const separator = entry.indexOf("|");
@@ -127,8 +127,8 @@ export function requireAuthConfig(config) {
127
127
  throw new ConfigError("Nothing says who may edit this site.\n" +
128
128
  " Authentication is not admission: your provider will happily vouch for\n" +
129
129
  " everybody it knows. Set at least one of these:\n\n" +
130
- " MICA_ADMIT_GROUP=mica-editors a claim your provider sends\n" +
131
- " MICA_ADMIT_DOMAIN=example.com every verified address there\n" +
130
+ " MICA_ADMIT_GROUPS=mica-editors a claim your provider sends\n" +
131
+ " MICA_ADMIT_DOMAINS=example.com every verified address there\n" +
132
132
  " MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\n" +
133
133
  " MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n\n" +
134
134
  " The group rule is the one to grow into: adding an editor is then a\n" +
@@ -136,7 +136,7 @@ export function requireAuthConfig(config) {
136
136
  }
137
137
  const open = config.admitDomains.filter((domain) => PUBLIC_EMAIL_DOMAINS.has(domain));
138
138
  if (open.length > 0) {
139
- throw new ConfigError(`MICA_ADMIT_DOMAIN names ${open.join(", ")}, where anybody can register an address.\n` +
139
+ throw new ConfigError(`MICA_ADMIT_DOMAINS names ${open.join(", ")}, where anybody can register an address.\n` +
140
140
  " That rule would let the public edit this site. Name the people instead:\n" +
141
141
  " MICA_ADMIT_EMAILS=alice@gmail.com,bob@gmail.com");
142
142
  }
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The auth gate's slice of the environment.\n *\n * A fragment, like the runtime's: the self-hosted app spreads `authEnv` into\n * its own schema, derives with `deriveAuth`, and passes the result here. The\n * local editor never touches any of this — these variables do not exist in\n * its configuration at all, which is a stronger statement than ignoring them.\n */\n\nimport { z } from \"zod\";\n\nimport { ConfigError, list } from \"@micacms/runtime\";\n\nexport const authEnv = {\n /**\n * OpenID Connect. Mica implements no passwords, no reset flow and no second\n * factor. It asks a provider who you are; the admission rules below decide\n * whether that person may edit this site.\n */\n MICA_OIDC_ISSUER: z.string().url().optional(),\n MICA_OIDC_CLIENT_ID: z.string().optional(),\n /** Omit for a public client using PKCE alone. */\n MICA_OIDC_CLIENT_SECRET: z.string().optional(),\n MICA_OIDC_SCOPE: z.string().default(\"openid profile email\"),\n /**\n * Which claim carries the person's groups. There is no standard one:\n * Keycloak and Entra normally send `groups`, others send `roles`, and some\n * send whatever the operator mapped. Naming it here is what keeps\n * `MICA_ADMIT_GROUP` working against any provider.\n */\n MICA_OIDC_GROUPS_CLAIM: z.string().default(\"groups\"),\n\n /**\n * Who may edit this site. Authentication says who somebody is; these say\n * whether that person gets in. At least one is required — the composition\n * refuses to start without one. Comma-separated, all four.\n */\n MICA_ADMIT_GROUP: z.string().optional(),\n MICA_ADMIT_DOMAIN: z.string().optional(),\n /** Verified addresses — the list for a provider with no groups, such as Google. */\n MICA_ADMIT_EMAILS: z.string().optional(),\n /** `issuer|subject` pairs — see admission.ts. */\n MICA_ADMIT_SUBJECTS: z.string().optional(),\n\n /** Signs session cookies. Rotating it invalidates every session. */\n SESSION_SECRET: z.string().min(32).optional(),\n /**\n * Deliberately short. Admission is configuration and is re-read on every\n * request; what cannot be immediate is a change *at the provider*, which\n * Mica only sees at the next sign-in. The session lifetime is therefore the\n * upper bound on how long a revoked editor keeps working.\n */\n SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),\n SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),\n\n LOGIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),\n LOGIN_WINDOW_MS: z.coerce\n .number()\n .int()\n .positive()\n .default(15 * 60 * 1000),\n};\n\nconst authSchema = z.object(authEnv);\nexport type AuthEnv = z.infer<typeof authSchema>;\n\n/** The derived values the gate reads, on top of the parsed environment. */\nexport function deriveAuth<T extends AuthEnv & { NODE_ENV: string; MICA_PUBLIC_URL: string }>(\n env: T,\n) {\n return {\n ...env,\n /**\n * Discovery over plain HTTP, for an identity provider running on\n * localhost. Never in production, and never for a remote host.\n */\n oidcAllowInsecure:\n env.NODE_ENV !== \"production\" &&\n /^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:|\\/|$)/.test(env.MICA_OIDC_ISSUER ?? \"\"),\n admitGroups: list(env.MICA_ADMIT_GROUP),\n // Lowercased: a domain is case-insensitive, and an operator who writes\n // `Example.com` means the same thing the provider spells `example.com`.\n admitDomains: list(env.MICA_ADMIT_DOMAIN).map((domain) =>\n domain.toLowerCase().replace(/^@/, \"\"),\n ),\n admitEmails: list(env.MICA_ADMIT_EMAILS).map((email) => email.toLowerCase()),\n admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {\n const separator = entry.indexOf(\"|\");\n return {\n issuer: separator < 0 ? \"\" : entry.slice(0, separator).trim(),\n subject: separator < 0 ? entry : entry.slice(separator + 1).trim(),\n };\n }),\n // Secure cookies are not sent over plain HTTP, which would break a\n // localhost trial run.\n cookieSecure: env.MICA_PUBLIC_URL.startsWith(\"https://\"),\n };\n}\n\n/** What the gate requires of a finished configuration. */\nexport interface AuthConfig {\n MICA_PUBLIC_URL: string;\n isProduction: boolean;\n MICA_OIDC_ISSUER?: string | undefined;\n MICA_OIDC_CLIENT_ID?: string | undefined;\n MICA_OIDC_CLIENT_SECRET?: string | undefined;\n MICA_OIDC_SCOPE: string;\n MICA_OIDC_GROUPS_CLAIM: string;\n oidcAllowInsecure: boolean;\n admitGroups: string[];\n admitDomains: string[];\n admitEmails: string[];\n admitSubjects: Array<{ issuer: string; subject: string }>;\n /** Resolved by the app: mandatory in production, ephemeral in development. */\n sessionSecret: string;\n cookieSecure: boolean;\n SESSION_TTL_HOURS: number;\n SESSION_IDLE_HOURS: number;\n LOGIN_MAX_ATTEMPTS: number;\n LOGIN_WINDOW_MS: number;\n}\n\n/**\n * Domains where anybody can register an address. A domain rule naming one\n * admits the public, so it is refused; name the addresses instead.\n */\nconst PUBLIC_EMAIL_DOMAINS = new Set([\n \"gmail.com\",\n \"googlemail.com\",\n \"outlook.com\",\n \"hotmail.com\",\n \"live.com\",\n \"msn.com\",\n \"yahoo.com\",\n \"icloud.com\",\n \"me.com\",\n \"aol.com\",\n \"proton.me\",\n \"protonmail.com\",\n \"gmx.com\",\n \"gmx.net\",\n \"gmx.de\",\n \"gmx.ch\",\n \"web.de\",\n]);\n\n/**\n * The refusals that make a deployment honest before it serves a request.\n *\n * An OIDC deployment that admits every person its issuer knows is a\n * misconfiguration, not a default — see decisions.md.\n */\nexport function requireAuthConfig(config: {\n MICA_PUBLIC_URL: string;\n MICA_OIDC_ISSUER?: string | undefined;\n MICA_OIDC_CLIENT_ID?: string | undefined;\n admitGroups: string[];\n admitDomains: string[];\n admitEmails: string[];\n admitSubjects: Array<{ issuer: string; subject: string }>;\n}): void {\n const needed = ([\"MICA_OIDC_ISSUER\", \"MICA_OIDC_CLIENT_ID\"] as const).filter(\n (key) => !config[key],\n );\n\n if (needed.length > 0) {\n throw new ConfigError(\n `This deployment authenticates with OpenID Connect, so ` +\n `${needed.join(\" and \")} must be set.\\n` +\n \" Mica implements no passwords. Point it at Keycloak, Authentik,\\n\" +\n \" ZITADEL, Entra, Okta, Google — anything that speaks OIDC.\\n\" +\n ` The redirect URI to register is ${config.MICA_PUBLIC_URL}/api/auth/callback`,\n );\n }\n\n if (\n config.admitGroups.length === 0 &&\n config.admitDomains.length === 0 &&\n config.admitEmails.length === 0 &&\n config.admitSubjects.length === 0\n ) {\n throw new ConfigError(\n \"Nothing says who may edit this site.\\n\" +\n \" Authentication is not admission: your provider will happily vouch for\\n\" +\n \" everybody it knows. Set at least one of these:\\n\\n\" +\n \" MICA_ADMIT_GROUP=mica-editors a claim your provider sends\\n\" +\n \" MICA_ADMIT_DOMAIN=example.com every verified address there\\n\" +\n \" MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\\n\" +\n \" MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\\n\\n\" +\n \" The group rule is the one to grow into: adding an editor is then a\\n\" +\n \" change in the identity provider, and Mica is not touched.\",\n );\n }\n\n const open = config.admitDomains.filter((domain) => PUBLIC_EMAIL_DOMAINS.has(domain));\n if (open.length > 0) {\n throw new ConfigError(\n `MICA_ADMIT_DOMAIN names ${open.join(\", \")}, where anybody can register an address.\\n` +\n \" That rule would let the public edit this site. Name the people instead:\\n\" +\n \" MICA_ADMIT_EMAILS=alice@gmail.com,bob@gmail.com\",\n );\n }\n\n const malformed = config.admitEmails.filter((email) => !/^[^@\\s]+@[^@\\s]+$/.test(email));\n if (malformed.length > 0) {\n throw new ConfigError(\n `MICA_ADMIT_EMAILS entries are email addresses, and these are not: ${malformed.join(\", \")}`,\n );\n }\n\n const wrong = config.admitSubjects.filter((entry) => !entry.issuer || !entry.subject);\n if (wrong.length > 0) {\n throw new ConfigError(\n \"MICA_ADMIT_SUBJECTS entries are `issuer|subject` pairs, and one is not:\\n\" +\n ` ${wrong.map((entry) => entry.subject || \"(empty)\").join(\", \")}\\n\\n` +\n \" The issuer is part of the key because a subject is only unique within\\n\" +\n \" the provider that issued it. To admit an address, use MICA_ADMIT_EMAILS.\\n\" +\n ` MICA_ADMIT_SUBJECTS=${config.MICA_OIDC_ISSUER ?? \"https://id.example.com\"}|a1b2c3`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAErD,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IAC7C,mBAAmB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC1C,iDAAiD;IACjD,uBAAuB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9C,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,sBAAsB,CAAC;IAC3D;;;;;OAKG;IACH,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC;IAEpD;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACxC,kBAAkB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACzC,mFAAmF;IACnF,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACxC,iDAAiD;IACjD,mBAAmB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAE1C,oEAAoE;IACpE,mBAAmB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE;IAClD;;;;;OAKG;IACH,sBAAsB,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;IACrE,uBAAuB,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;IAEtE,wBAAwB,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC;IACxE,qBAAqB,EAAE,CAAC,CAAC,MAAM;SAC5B,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,OAAO,CAAC,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAGrC,2EAA2E;AAC3E,MAAM,UAAU,UAAU,CACxB,GAAM;IAEN,OAAO;QACL,GAAG,GAAG;QACN;;;WAGG;QACH,iBAAiB,EACf,GAAG,CAAC,QAAQ,KAAK,YAAY;YAC7B,4CAA4C,CAAC,IAAI,CAAC,GAAG,CAAC,gBAAgB,IAAI,EAAE,CAAC;QAC/E,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACxC,uEAAuE;QACvE,wEAAwE;QACxE,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CACxD,MAAM,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CACvC;QACD,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAC5E,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;YACzD,MAAM,SAAS,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACrC,OAAO;gBACL,MAAM,EAAE,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,IAAI,EAAE;gBAC7D,OAAO,EAAE,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE;aACnE,CAAC;QACJ,CAAC,CAAC;QACF,mEAAmE;QACnE,uBAAuB;QACvB,YAAY,EAAE,GAAG,CAAC,eAAe,CAAC,UAAU,CAAC,UAAU,CAAC;KACzD,CAAC;AACJ,CAAC;AAyBD;;;GAGG;AACH,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACnC,WAAW;IACX,gBAAgB;IAChB,aAAa;IACb,aAAa;IACb,UAAU;IACV,SAAS;IACT,WAAW;IACX,YAAY;IACZ,QAAQ;IACR,SAAS;IACT,WAAW;IACX,gBAAgB;IAChB,SAAS;IACT,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,QAAQ;CACT,CAAC,CAAC;AAEH;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAQjC;IACC,MAAM,MAAM,GAAI,CAAC,kBAAkB,EAAE,qBAAqB,CAAW,CAAC,MAAM,CAC1E,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CACtB,CAAC;IAEF,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,WAAW,CACnB,wDAAwD;YACtD,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,iBAAiB;YACxC,oEAAoE;YACpE,+DAA+D;YAC/D,qCAAqC,MAAM,CAAC,eAAe,oBAAoB,CAClF,CAAC;IACJ,CAAC;IAED,IACE,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC;QAC/B,MAAM,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC;QAChC,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC;QAC/B,MAAM,CAAC,aAAa,CAAC,MAAM,KAAK,CAAC,EACjC,CAAC;QACD,MAAM,IAAI,WAAW,CACnB,wCAAwC;YACtC,2EAA2E;YAC3E,sDAAsD;YACtD,6EAA6E;YAC7E,8EAA8E;YAC9E,yEAAyE;YACzE,2DAA2D;YAC3D,wEAAwE;YACxE,6DAA6D,CAChE,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,oBAAoB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACtF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpB,MAAM,IAAI,WAAW,CACnB,4BAA4B,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,4CAA4C;YACrF,6EAA6E;YAC7E,qDAAqD,CACxD,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IACzF,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,WAAW,CACnB,qEAAqE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAC5F,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACtF,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrB,MAAM,IAAI,WAAW,CACnB,2EAA2E;YACzE,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,IAAI,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM;YACxE,2EAA2E;YAC3E,8EAA8E;YAC9E,2BAA2B,MAAM,CAAC,gBAAgB,IAAI,wBAAwB,SAAS,CAC1F,CAAC;IACJ,CAAC;AACH,CAAC","sourcesContent":["/**\n * The auth gate's slice of the environment.\n *\n * A fragment, like the runtime's: the self-hosted app spreads `authEnv` into\n * its own schema, derives with `deriveAuth`, and passes the result here. The\n * local editor never touches any of this — these variables do not exist in\n * its configuration at all, which is a stronger statement than ignoring them.\n */\n\nimport { z } from \"zod\";\n\nimport { ConfigError, list } from \"@micacms/runtime\";\n\nexport const authEnv = {\n /**\n * OpenID Connect. Mica implements no passwords, no reset flow and no second\n * factor. It asks a provider who you are; the admission rules below decide\n * whether that person may edit this site.\n */\n MICA_OIDC_ISSUER: z.string().url().optional(),\n MICA_OIDC_CLIENT_ID: z.string().optional(),\n /** Omit for a public client using PKCE alone. */\n MICA_OIDC_CLIENT_SECRET: z.string().optional(),\n MICA_OIDC_SCOPE: z.string().default(\"openid profile email\"),\n /**\n * Which claim carries the person's groups. There is no standard one:\n * Keycloak and Entra normally send `groups`, others send `roles`, and some\n * send whatever the operator mapped. Naming it here is what keeps\n * `MICA_ADMIT_GROUPS` working against any provider.\n */\n MICA_OIDC_GROUPS_CLAIM: z.string().default(\"groups\"),\n\n /**\n * Who may edit this site. Authentication says who somebody is; these say\n * whether that person gets in. At least one is required — the composition\n * refuses to start without one. Comma-separated, all four.\n */\n MICA_ADMIT_GROUPS: z.string().optional(),\n MICA_ADMIT_DOMAINS: z.string().optional(),\n /** Verified addresses — the list for a provider with no groups, such as Google. */\n MICA_ADMIT_EMAILS: z.string().optional(),\n /** `issuer|subject` pairs — see admission.ts. */\n MICA_ADMIT_SUBJECTS: z.string().optional(),\n\n /** Signs session cookies. Rotating it invalidates every session. */\n MICA_SESSION_SECRET: z.string().min(32).optional(),\n /**\n * Deliberately short. Admission is configuration and is re-read on every\n * request; what cannot be immediate is a change *at the provider*, which\n * Mica only sees at the next sign-in. The session lifetime is therefore the\n * upper bound on how long a revoked editor keeps working.\n */\n MICA_SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),\n MICA_SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),\n\n MICA_SIGNIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),\n MICA_SIGNIN_WINDOW_MS: z.coerce\n .number()\n .int()\n .positive()\n .default(15 * 60 * 1000),\n};\n\nconst authSchema = z.object(authEnv);\nexport type AuthEnv = z.infer<typeof authSchema>;\n\n/** The derived values the gate reads, on top of the parsed environment. */\nexport function deriveAuth<T extends AuthEnv & { NODE_ENV: string; MICA_PUBLIC_URL: string }>(\n env: T,\n) {\n return {\n ...env,\n /**\n * Discovery over plain HTTP, for an identity provider running on\n * localhost. Never in production, and never for a remote host.\n */\n oidcAllowInsecure:\n env.NODE_ENV !== \"production\" &&\n /^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:|\\/|$)/.test(env.MICA_OIDC_ISSUER ?? \"\"),\n admitGroups: list(env.MICA_ADMIT_GROUPS),\n // Lowercased: a domain is case-insensitive, and an operator who writes\n // `Example.com` means the same thing the provider spells `example.com`.\n admitDomains: list(env.MICA_ADMIT_DOMAINS).map((domain) =>\n domain.toLowerCase().replace(/^@/, \"\"),\n ),\n admitEmails: list(env.MICA_ADMIT_EMAILS).map((email) => email.toLowerCase()),\n admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {\n const separator = entry.indexOf(\"|\");\n return {\n issuer: separator < 0 ? \"\" : entry.slice(0, separator).trim(),\n subject: separator < 0 ? entry : entry.slice(separator + 1).trim(),\n };\n }),\n // Secure cookies are not sent over plain HTTP, which would break a\n // localhost trial run.\n cookieSecure: env.MICA_PUBLIC_URL.startsWith(\"https://\"),\n };\n}\n\n/** What the gate requires of a finished configuration. */\nexport interface AuthConfig {\n MICA_PUBLIC_URL: string;\n isProduction: boolean;\n MICA_OIDC_ISSUER?: string | undefined;\n MICA_OIDC_CLIENT_ID?: string | undefined;\n MICA_OIDC_CLIENT_SECRET?: string | undefined;\n MICA_OIDC_SCOPE: string;\n MICA_OIDC_GROUPS_CLAIM: string;\n oidcAllowInsecure: boolean;\n admitGroups: string[];\n admitDomains: string[];\n admitEmails: string[];\n admitSubjects: Array<{ issuer: string; subject: string }>;\n /** Resolved by the app: mandatory in production, ephemeral in development. */\n sessionSecret: string;\n cookieSecure: boolean;\n MICA_SESSION_TTL_HOURS: number;\n MICA_SESSION_IDLE_HOURS: number;\n MICA_SIGNIN_MAX_ATTEMPTS: number;\n MICA_SIGNIN_WINDOW_MS: number;\n}\n\n/**\n * Domains where anybody can register an address. A domain rule naming one\n * admits the public, so it is refused; name the addresses instead.\n */\nconst PUBLIC_EMAIL_DOMAINS = new Set([\n \"gmail.com\",\n \"googlemail.com\",\n \"outlook.com\",\n \"hotmail.com\",\n \"live.com\",\n \"msn.com\",\n \"yahoo.com\",\n \"icloud.com\",\n \"me.com\",\n \"aol.com\",\n \"proton.me\",\n \"protonmail.com\",\n \"gmx.com\",\n \"gmx.net\",\n \"gmx.de\",\n \"gmx.ch\",\n \"web.de\",\n]);\n\n/**\n * The refusals that make a deployment honest before it serves a request.\n *\n * An OIDC deployment that admits every person its issuer knows is a\n * misconfiguration, not a default — see decisions.md.\n */\nexport function requireAuthConfig(config: {\n MICA_PUBLIC_URL: string;\n MICA_OIDC_ISSUER?: string | undefined;\n MICA_OIDC_CLIENT_ID?: string | undefined;\n admitGroups: string[];\n admitDomains: string[];\n admitEmails: string[];\n admitSubjects: Array<{ issuer: string; subject: string }>;\n}): void {\n const needed = ([\"MICA_OIDC_ISSUER\", \"MICA_OIDC_CLIENT_ID\"] as const).filter(\n (key) => !config[key],\n );\n\n if (needed.length > 0) {\n throw new ConfigError(\n `This deployment authenticates with OpenID Connect, so ` +\n `${needed.join(\" and \")} must be set.\\n` +\n \" Mica implements no passwords. Point it at Keycloak, Authentik,\\n\" +\n \" ZITADEL, Entra, Okta, Google — anything that speaks OIDC.\\n\" +\n ` The redirect URI to register is ${config.MICA_PUBLIC_URL}/api/auth/callback`,\n );\n }\n\n if (\n config.admitGroups.length === 0 &&\n config.admitDomains.length === 0 &&\n config.admitEmails.length === 0 &&\n config.admitSubjects.length === 0\n ) {\n throw new ConfigError(\n \"Nothing says who may edit this site.\\n\" +\n \" Authentication is not admission: your provider will happily vouch for\\n\" +\n \" everybody it knows. Set at least one of these:\\n\\n\" +\n \" MICA_ADMIT_GROUPS=mica-editors a claim your provider sends\\n\" +\n \" MICA_ADMIT_DOMAINS=example.com every verified address there\\n\" +\n \" MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\\n\" +\n \" MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\\n\\n\" +\n \" The group rule is the one to grow into: adding an editor is then a\\n\" +\n \" change in the identity provider, and Mica is not touched.\",\n );\n }\n\n const open = config.admitDomains.filter((domain) => PUBLIC_EMAIL_DOMAINS.has(domain));\n if (open.length > 0) {\n throw new ConfigError(\n `MICA_ADMIT_DOMAINS names ${open.join(\", \")}, where anybody can register an address.\\n` +\n \" That rule would let the public edit this site. Name the people instead:\\n\" +\n \" MICA_ADMIT_EMAILS=alice@gmail.com,bob@gmail.com\",\n );\n }\n\n const malformed = config.admitEmails.filter((email) => !/^[^@\\s]+@[^@\\s]+$/.test(email));\n if (malformed.length > 0) {\n throw new ConfigError(\n `MICA_ADMIT_EMAILS entries are email addresses, and these are not: ${malformed.join(\", \")}`,\n );\n }\n\n const wrong = config.admitSubjects.filter((entry) => !entry.issuer || !entry.subject);\n if (wrong.length > 0) {\n throw new ConfigError(\n \"MICA_ADMIT_SUBJECTS entries are `issuer|subject` pairs, and one is not:\\n\" +\n ` ${wrong.map((entry) => entry.subject || \"(empty)\").join(\", \")}\\n\\n` +\n \" The issuer is part of the key because a subject is only unique within\\n\" +\n \" the provider that issued it. To admit an address, use MICA_ADMIT_EMAILS.\\n\" +\n ` MICA_ADMIT_SUBJECTS=${config.MICA_OIDC_ISSUER ?? \"https://id.example.com\"}|a1b2c3`,\n );\n }\n}\n"]}
package/dist/index.js CHANGED
@@ -22,8 +22,8 @@ export function createOidcAuth(deps) {
22
22
  const admission = createAdmissionPolicy(config);
23
23
  const oidc = createOidcService({ config, logger });
24
24
  const loginLimiter = createRateLimiter({
25
- limit: config.LOGIN_MAX_ATTEMPTS,
26
- windowMs: config.LOGIN_WINDOW_MS,
25
+ limit: config.MICA_SIGNIN_MAX_ATTEMPTS,
26
+ windowMs: config.MICA_SIGNIN_WINDOW_MS,
27
27
  });
28
28
  const gate = { sessions, admission };
29
29
  return {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,iBAAiB,EAAoB,MAAM,kBAAkB,CAAC;AAEvE,OAAO,EAAE,qBAAqB,EAAwB,MAAM,gBAAgB,CAAC;AAE7E,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,oBAAoB,EAAuB,MAAM,cAAc,CAAC;AAgBzE,MAAM,UAAU,cAAc,CAAC,IAAkB;IAC/C,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAEhC,MAAM,QAAQ,GAAG,oBAAoB,CAAC,MAAM,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAChD,MAAM,IAAI,GAAG,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACnD,MAAM,YAAY,GAAG,iBAAiB,CAAC;QACrC,KAAK,EAAE,MAAM,CAAC,kBAAkB;QAChC,QAAQ,EAAE,MAAM,CAAC,eAAe;KACjC,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;IAErC,OAAO;QACL,OAAO,EAAE;YACP,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;YACvE,IAAI,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC;SAClD;QACD,QAAQ;QACR,SAAS;QACT,IAAI;QACJ,YAAY;KACb,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,qBAAqB,EAA8C,MAAM,gBAAgB,CAAC;AACnG,OAAO,EACL,OAAO,EACP,UAAU,EACV,iBAAiB,GAGlB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EACL,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,cAAc,GAIf,MAAM,cAAc,CAAC","sourcesContent":["/**\n * @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.\n *\n * One factory: hand it the finished configuration and a logger, and it\n * returns the `AuthSurface` the runtime mounts — sign-in\n * routes at `/api/auth`, and the session-plus-CSRF gate in front of the admin\n * API — together with the services the composing app reports on.\n *\n * This package is a dependency of `@micacms/self-hosted` and of nothing else.\n * The local editor must never gain a path to it: that absence is the product\n * boundary that keeps `npx @micacms/editor` free of authentication code.\n */\n\nimport type { AuthSurface, Logger } from \"@micacms/runtime\";\nimport { createRateLimiter, type RateLimiter } from \"@micacms/runtime\";\n\nimport { createAdmissionPolicy, type AdmissionPolicy } from \"./admission.js\";\nimport type { AuthConfig } from \"./config.js\";\nimport { csrfProtection, requireAccess } from \"./gate.js\";\nimport { createOidcService, type OidcService } from \"./oidc.js\";\nimport { authRoutes } from \"./routes.js\";\nimport { createSessionService, type SessionService } from \"./session.js\";\n\nexport interface OidcAuthDeps {\n config: AuthConfig;\n logger: Logger;\n}\n\nexport interface OidcAuth {\n /** What `createApp` mounts: routes at /api/auth, the gate before /api/admin. */\n surface: AuthSurface;\n sessions: SessionService;\n admission: AdmissionPolicy;\n oidc: OidcService;\n loginLimiter: RateLimiter;\n}\n\nexport function createOidcAuth(deps: OidcAuthDeps): OidcAuth {\n const { config, logger } = deps;\n\n const sessions = createSessionService(config);\n const admission = createAdmissionPolicy(config);\n const oidc = createOidcService({ config, logger });\n const loginLimiter = createRateLimiter({\n limit: config.LOGIN_MAX_ATTEMPTS,\n windowMs: config.LOGIN_WINDOW_MS,\n });\n\n const gate = { sessions, admission };\n\n return {\n surface: {\n routes: authRoutes({ config, sessions, oidc, admission, loginLimiter }),\n gate: [requireAccess(gate), csrfProtection(gate)],\n },\n sessions,\n admission,\n oidc,\n loginLimiter,\n };\n}\n\nexport { createAdmissionPolicy, type AdmissionPolicy, type AdmissionResult } from \"./admission.js\";\nexport {\n authEnv,\n deriveAuth,\n requireAuthConfig,\n type AuthConfig,\n type AuthEnv,\n} from \"./config.js\";\nexport { csrfProtection, requireAccess, sessionOf } from \"./gate.js\";\nexport { createOidcService, type OidcService } from \"./oidc.js\";\nexport {\n cookieNames,\n createSessionService,\n CSRF_COOKIE,\n CSRF_HEADER,\n SESSION_COOKIE,\n type SessionIdentity,\n type SessionRecord,\n type SessionService,\n} from \"./session.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,iBAAiB,EAAoB,MAAM,kBAAkB,CAAC;AAEvE,OAAO,EAAE,qBAAqB,EAAwB,MAAM,gBAAgB,CAAC;AAE7E,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,oBAAoB,EAAuB,MAAM,cAAc,CAAC;AAgBzE,MAAM,UAAU,cAAc,CAAC,IAAkB;IAC/C,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAEhC,MAAM,QAAQ,GAAG,oBAAoB,CAAC,MAAM,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAChD,MAAM,IAAI,GAAG,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACnD,MAAM,YAAY,GAAG,iBAAiB,CAAC;QACrC,KAAK,EAAE,MAAM,CAAC,wBAAwB;QACtC,QAAQ,EAAE,MAAM,CAAC,qBAAqB;KACvC,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;IAErC,OAAO;QACL,OAAO,EAAE;YACP,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;YACvE,IAAI,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC;SAClD;QACD,QAAQ;QACR,SAAS;QACT,IAAI;QACJ,YAAY;KACb,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,qBAAqB,EAA8C,MAAM,gBAAgB,CAAC;AACnG,OAAO,EACL,OAAO,EACP,UAAU,EACV,iBAAiB,GAGlB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EACL,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,cAAc,GAIf,MAAM,cAAc,CAAC","sourcesContent":["/**\n * @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.\n *\n * One factory: hand it the finished configuration and a logger, and it\n * returns the `AuthSurface` the runtime mounts — sign-in\n * routes at `/api/auth`, and the session-plus-CSRF gate in front of the admin\n * API — together with the services the composing app reports on.\n *\n * This package is a dependency of `@micacms/self-hosted` and of nothing else.\n * The local editor must never gain a path to it: that absence is the product\n * boundary that keeps `npx @micacms/editor` free of authentication code.\n */\n\nimport type { AuthSurface, Logger } from \"@micacms/runtime\";\nimport { createRateLimiter, type RateLimiter } from \"@micacms/runtime\";\n\nimport { createAdmissionPolicy, type AdmissionPolicy } from \"./admission.js\";\nimport type { AuthConfig } from \"./config.js\";\nimport { csrfProtection, requireAccess } from \"./gate.js\";\nimport { createOidcService, type OidcService } from \"./oidc.js\";\nimport { authRoutes } from \"./routes.js\";\nimport { createSessionService, type SessionService } from \"./session.js\";\n\nexport interface OidcAuthDeps {\n config: AuthConfig;\n logger: Logger;\n}\n\nexport interface OidcAuth {\n /** What `createApp` mounts: routes at /api/auth, the gate before /api/admin. */\n surface: AuthSurface;\n sessions: SessionService;\n admission: AdmissionPolicy;\n oidc: OidcService;\n loginLimiter: RateLimiter;\n}\n\nexport function createOidcAuth(deps: OidcAuthDeps): OidcAuth {\n const { config, logger } = deps;\n\n const sessions = createSessionService(config);\n const admission = createAdmissionPolicy(config);\n const oidc = createOidcService({ config, logger });\n const loginLimiter = createRateLimiter({\n limit: config.MICA_SIGNIN_MAX_ATTEMPTS,\n windowMs: config.MICA_SIGNIN_WINDOW_MS,\n });\n\n const gate = { sessions, admission };\n\n return {\n surface: {\n routes: authRoutes({ config, sessions, oidc, admission, loginLimiter }),\n gate: [requireAccess(gate), csrfProtection(gate)],\n },\n sessions,\n admission,\n oidc,\n loginLimiter,\n };\n}\n\nexport { createAdmissionPolicy, type AdmissionPolicy, type AdmissionResult } from \"./admission.js\";\nexport {\n authEnv,\n deriveAuth,\n requireAuthConfig,\n type AuthConfig,\n type AuthEnv,\n} from \"./config.js\";\nexport { csrfProtection, requireAccess, sessionOf } from \"./gate.js\";\nexport { createOidcService, type OidcService } from \"./oidc.js\";\nexport {\n cookieNames,\n createSessionService,\n CSRF_COOKIE,\n CSRF_HEADER,\n SESSION_COOKIE,\n type SessionIdentity,\n type SessionRecord,\n type SessionService,\n} from \"./session.js\";\n"]}
package/dist/session.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Session management.
3
3
  *
4
4
  * The session is the cookie: who the provider said this is, a CSRF token and
5
- * an expiry, encrypted with a key derived from SESSION_SECRET. There is no
5
+ * an expiry, encrypted with a key derived from MICA_SESSION_SECRET. There is no
6
6
  * session table, so Mica stores nothing about people at all.
7
7
  *
8
8
  * - AES-256-GCM, so the cookie can be neither read nor altered. Encrypted and
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * What this gives up is server-side revocation of one cookie. Sign-out clears
16
16
  * it in the browser; a copy taken before then stays valid until it expires.
17
- * Rotating SESSION_SECRET ends every session at once. The admission rules are
17
+ * Rotating MICA_SESSION_SECRET ends every session at once. The admission rules are
18
18
  * still re-read on every request — see gate.ts and docs/decisions.md.
19
19
  */
20
20
  import type { Context } from "hono";
package/dist/session.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Session management.
3
3
  *
4
4
  * The session is the cookie: who the provider said this is, a CSRF token and
5
- * an expiry, encrypted with a key derived from SESSION_SECRET. There is no
5
+ * an expiry, encrypted with a key derived from MICA_SESSION_SECRET. There is no
6
6
  * session table, so Mica stores nothing about people at all.
7
7
  *
8
8
  * - AES-256-GCM, so the cookie can be neither read nor altered. Encrypted and
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * What this gives up is server-side revocation of one cookie. Sign-out clears
16
16
  * it in the browser; a copy taken before then stays valid until it expires.
17
- * Rotating SESSION_SECRET ends every session at once. The admission rules are
17
+ * Rotating MICA_SESSION_SECRET ends every session at once. The admission rules are
18
18
  * still re-read on every request — see gate.ts and docs/decisions.md.
19
19
  */
20
20
  import { createCipheriv, createDecipheriv, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
@@ -98,8 +98,8 @@ function parseRecord(json) {
98
98
  }
99
99
  }
100
100
  export function createSessionService(config) {
101
- const ttlMs = config.SESSION_TTL_HOURS * 3600_000;
102
- const idleMs = config.SESSION_IDLE_HOURS * 3600_000;
101
+ const ttlMs = config.MICA_SESSION_TTL_HOURS * 3600_000;
102
+ const idleMs = config.MICA_SESSION_IDLE_HOURS * 3600_000;
103
103
  const key = Buffer.from(hkdfSync("sha256", config.sessionSecret, "", "mica session cookie v1", 32));
104
104
  // Only the groups a rule names are kept: an Entra token can carry hundreds,
105
105
  // and a cookie holds 4 KB. A group added to the rules later counts from the
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session management.\n *\n * The session is the cookie: who the provider said this is, a CSRF token and\n * an expiry, encrypted with a key derived from SESSION_SECRET. There is no\n * session table, so Mica stores nothing about people at all.\n *\n * - AES-256-GCM, so the cookie can be neither read nor altered. Encrypted and\n * not only signed, because it carries an email address and groups.\n * - A fresh random IV per cookie, and a new cookie at every sign-in, so a\n * client-supplied value is never adopted (session fixation).\n * - The idle window slides: an active session is re-issued every few\n * minutes with a later expiry, capped by the absolute lifetime.\n *\n * What this gives up is server-side revocation of one cookie. Sign-out clears\n * it in the browser; a copy taken before then stays valid until it expires.\n * Rotating SESSION_SECRET ends every session at once. The admission rules are\n * still re-read on every request — see gate.ts and docs/decisions.md.\n */\n\nimport { createCipheriv, createDecipheriv, hkdfSync, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nimport type { Context } from \"hono\";\nimport { setCookie } from \"hono/cookie\";\n\nimport { randomToken, type Env } from \"@micacms/runtime\";\n\nimport type { AuthConfig } from \"./config.js\";\n\nexport const SESSION_COOKIE = \"mica_session\";\nexport const CSRF_COOKIE = \"mica_csrf\";\nexport const CSRF_HEADER = \"x-csrf-token\";\nexport const TRANSACTION_COOKIE = \"mica_oidc\";\n\nexport interface CookieNames {\n session: string;\n csrf: string;\n transaction: string;\n}\n\n/**\n * With https, the names carry a prefix the browser enforces: `__Host-` means\n * Secure, path `/` and no Domain, so a sibling subdomain cannot plant or\n * overwrite the cookie. The transaction cookie is scoped to `/api/auth`, so it\n * can only be `__Secure-`. Over plain HTTP the cookies are not Secure, and a\n * browser would refuse a prefixed name.\n */\nexport function cookieNames(secure: boolean): CookieNames {\n return secure\n ? {\n session: `__Host-${SESSION_COOKIE}`,\n csrf: `__Host-${CSRF_COOKIE}`,\n transaction: `__Secure-${TRANSACTION_COOKIE}`,\n }\n : { session: SESSION_COOKIE, csrf: CSRF_COOKIE, transaction: TRANSACTION_COOKIE };\n}\n\n/** How often an active session is re-issued with a later idle expiry. */\nconst REFRESH_INTERVAL_MS = 5 * 60 * 1000;\n\n/** Browsers drop cookies over 4096 bytes; refuse rather than sign in to nothing. */\nconst MAX_COOKIE_BYTES = 4000;\n\nconst IV_BYTES = 12;\nconst TAG_BYTES = 16;\n\n/** What the identity provider stated about the person holding this session. */\nexport interface SessionIdentity {\n issuer: string;\n /** Opaque and stable at that issuer. */\n subject: string;\n email: string;\n /** The provider's own `email_verified`. Absent counts as false. */\n emailVerified: boolean;\n name: string;\n /** The groups claim, reduced at sign-in to the groups the rules name. */\n groups: string[];\n}\n\nexport interface SessionRecord {\n identity: SessionIdentity;\n csrfToken: string;\n createdAt: string;\n /** When the session was last issued; drives the sliding idle window. */\n refreshedAt: string;\n expiresAt: string;\n}\n\nexport interface SessionService {\n /** The cookie names, which depend on whether the cookies are Secure. */\n readonly names: CookieNames;\n /** Seals a new session. Throws when the identity cannot fit in a cookie. */\n create(identity: SessionIdentity): { record: SessionRecord; cookie: string };\n /** The live session in a cookie, or null for a missing, forged or expired one. */\n resolve(cookieValue: string | undefined): SessionRecord | null;\n /** A later-expiring copy of an active session, or null while it is fresh. */\n refresh(record: SessionRecord): { record: SessionRecord; cookie: string } | null;\n sessionCookieOptions(): CookieOptions;\n csrfCookieOptions(): CookieOptions;\n clearedCookieOptions(): CookieOptions;\n}\n\nexport interface CookieOptions {\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n maxAge: number;\n}\n\n/** Constant-time string comparison that tolerates differing lengths. */\nexport function safeEqual(a: string, b: string): boolean {\n const left = Buffer.from(a);\n const right = Buffer.from(b);\n if (left.length !== right.length) {\n // Still compare, so the branch does not leak the length difference.\n timingSafeEqual(left, left);\n return false;\n }\n return timingSafeEqual(left, right);\n}\n\n/**\n * Keycloak sends `/mica-editors` where the rule says `mica-editors`; both\n * spellings are the same group — see admission.ts.\n */\nconst groupName = (group: string): string => group.replace(/^\\//, \"\");\n\n/** Defensive read of a decrypted payload: it was written by us, but checked anyway. */\nfunction parseRecord(json: string): SessionRecord | null {\n try {\n const value = JSON.parse(json) as Partial<SessionRecord>;\n const identity = value.identity;\n if (\n !identity ||\n typeof identity.issuer !== \"string\" ||\n typeof identity.subject !== \"string\" ||\n typeof value.csrfToken !== \"string\" ||\n typeof value.createdAt !== \"string\" ||\n typeof value.refreshedAt !== \"string\" ||\n typeof value.expiresAt !== \"string\"\n ) {\n return null;\n }\n return {\n identity: {\n issuer: identity.issuer,\n subject: identity.subject,\n email: typeof identity.email === \"string\" ? identity.email : \"\",\n emailVerified: identity.emailVerified === true,\n name: typeof identity.name === \"string\" ? identity.name : \"\",\n groups: Array.isArray(identity.groups)\n ? identity.groups.filter((group): group is string => typeof group === \"string\")\n : [],\n },\n csrfToken: value.csrfToken,\n createdAt: value.createdAt,\n refreshedAt: value.refreshedAt,\n expiresAt: value.expiresAt,\n };\n } catch {\n return null;\n }\n}\n\nexport function createSessionService(config: AuthConfig): SessionService {\n const ttlMs = config.SESSION_TTL_HOURS * 3600_000;\n const idleMs = config.SESSION_IDLE_HOURS * 3600_000;\n\n const key = Buffer.from(\n hkdfSync(\"sha256\", config.sessionSecret, \"\", \"mica session cookie v1\", 32),\n );\n\n // Only the groups a rule names are kept: an Entra token can carry hundreds,\n // and a cookie holds 4 KB. A group added to the rules later counts from the\n // next sign-in.\n const admitted = new Set(config.admitGroups.map(groupName));\n\n /** Expiry is the earlier of the idle window and the absolute lifetime. */\n const nextExpiry = (createdAt: number, now: number): string =>\n new Date(Math.min(now + idleMs, createdAt + ttlMs)).toISOString();\n\n const seal = (record: SessionRecord): string => {\n const iv = randomBytes(IV_BYTES);\n const cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n const body = Buffer.concat([cipher.update(JSON.stringify(record), \"utf8\"), cipher.final()]);\n const cookie = Buffer.concat([iv, body, cipher.getAuthTag()]).toString(\"base64url\");\n\n if (cookie.length > MAX_COOKIE_BYTES) {\n throw new Error(\"The session does not fit in a cookie; the identity is too large.\");\n }\n return cookie;\n };\n\n const open = (cookieValue: string): SessionRecord | null => {\n const raw = Buffer.from(cookieValue, \"base64url\");\n if (raw.length <= IV_BYTES + TAG_BYTES) return null;\n\n try {\n const decipher = createDecipheriv(\"aes-256-gcm\", key, raw.subarray(0, IV_BYTES));\n decipher.setAuthTag(raw.subarray(raw.length - TAG_BYTES));\n const json = Buffer.concat([\n decipher.update(raw.subarray(IV_BYTES, raw.length - TAG_BYTES)),\n decipher.final(),\n ]).toString(\"utf8\");\n return parseRecord(json);\n } catch {\n // Forged, truncated, or sealed with a secret that has since rotated.\n return null;\n }\n };\n\n return {\n names: cookieNames(config.cookieSecure),\n\n create(identity) {\n const now = Date.now();\n const record: SessionRecord = {\n identity: {\n ...identity,\n groups: identity.groups.filter((group) => admitted.has(groupName(group))),\n },\n csrfToken: randomToken(32),\n createdAt: new Date(now).toISOString(),\n refreshedAt: new Date(now).toISOString(),\n expiresAt: nextExpiry(now, now),\n };\n return { record, cookie: seal(record) };\n },\n\n resolve(cookieValue) {\n if (!cookieValue) return null;\n const record = open(cookieValue);\n if (!record || Date.parse(record.expiresAt) <= Date.now()) return null;\n return record;\n },\n\n refresh(record) {\n const now = Date.now();\n if (now - Date.parse(record.refreshedAt) < REFRESH_INTERVAL_MS) return null;\n\n const next: SessionRecord = {\n ...record,\n refreshedAt: new Date(now).toISOString(),\n expiresAt: nextExpiry(Date.parse(record.createdAt), now),\n };\n return { record: next, cookie: seal(next) };\n },\n\n sessionCookieOptions() {\n return {\n path: \"/\",\n httpOnly: true,\n secure: config.cookieSecure,\n // Lax keeps the cookie on top-level navigations back to the admin\n // while blocking it on cross-site form posts.\n sameSite: \"Lax\",\n maxAge: Math.floor(ttlMs / 1000),\n };\n },\n\n csrfCookieOptions() {\n return {\n path: \"/\",\n // Readable by the SPA on purpose: it echoes the value in a header,\n // which is what makes the double-submit check work.\n httpOnly: false,\n secure: config.cookieSecure,\n sameSite: \"Lax\",\n maxAge: Math.floor(ttlMs / 1000),\n };\n },\n\n clearedCookieOptions() {\n return {\n path: \"/\",\n httpOnly: true,\n secure: config.cookieSecure,\n sameSite: \"Lax\",\n maxAge: 0,\n };\n },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Starting one\n * ------------------------------------------------------------------ */\n\n/** Issues the session cookies for a browser that has just been identified. */\nexport function establishSession(\n c: Context<Env>,\n sessions: SessionService,\n identity: SessionIdentity,\n): void {\n const { record, cookie } = sessions.create(identity);\n\n setCookie(c, sessions.names.session, cookie, sessions.sessionCookieOptions());\n setCookie(c, sessions.names.csrf, record.csrfToken, sessions.csrfCookieOptions());\n}\n"]}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session management.\n *\n * The session is the cookie: who the provider said this is, a CSRF token and\n * an expiry, encrypted with a key derived from MICA_SESSION_SECRET. There is no\n * session table, so Mica stores nothing about people at all.\n *\n * - AES-256-GCM, so the cookie can be neither read nor altered. Encrypted and\n * not only signed, because it carries an email address and groups.\n * - A fresh random IV per cookie, and a new cookie at every sign-in, so a\n * client-supplied value is never adopted (session fixation).\n * - The idle window slides: an active session is re-issued every few\n * minutes with a later expiry, capped by the absolute lifetime.\n *\n * What this gives up is server-side revocation of one cookie. Sign-out clears\n * it in the browser; a copy taken before then stays valid until it expires.\n * Rotating MICA_SESSION_SECRET ends every session at once. The admission rules are\n * still re-read on every request — see gate.ts and docs/decisions.md.\n */\n\nimport { createCipheriv, createDecipheriv, hkdfSync, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nimport type { Context } from \"hono\";\nimport { setCookie } from \"hono/cookie\";\n\nimport { randomToken, type Env } from \"@micacms/runtime\";\n\nimport type { AuthConfig } from \"./config.js\";\n\nexport const SESSION_COOKIE = \"mica_session\";\nexport const CSRF_COOKIE = \"mica_csrf\";\nexport const CSRF_HEADER = \"x-csrf-token\";\nexport const TRANSACTION_COOKIE = \"mica_oidc\";\n\nexport interface CookieNames {\n session: string;\n csrf: string;\n transaction: string;\n}\n\n/**\n * With https, the names carry a prefix the browser enforces: `__Host-` means\n * Secure, path `/` and no Domain, so a sibling subdomain cannot plant or\n * overwrite the cookie. The transaction cookie is scoped to `/api/auth`, so it\n * can only be `__Secure-`. Over plain HTTP the cookies are not Secure, and a\n * browser would refuse a prefixed name.\n */\nexport function cookieNames(secure: boolean): CookieNames {\n return secure\n ? {\n session: `__Host-${SESSION_COOKIE}`,\n csrf: `__Host-${CSRF_COOKIE}`,\n transaction: `__Secure-${TRANSACTION_COOKIE}`,\n }\n : { session: SESSION_COOKIE, csrf: CSRF_COOKIE, transaction: TRANSACTION_COOKIE };\n}\n\n/** How often an active session is re-issued with a later idle expiry. */\nconst REFRESH_INTERVAL_MS = 5 * 60 * 1000;\n\n/** Browsers drop cookies over 4096 bytes; refuse rather than sign in to nothing. */\nconst MAX_COOKIE_BYTES = 4000;\n\nconst IV_BYTES = 12;\nconst TAG_BYTES = 16;\n\n/** What the identity provider stated about the person holding this session. */\nexport interface SessionIdentity {\n issuer: string;\n /** Opaque and stable at that issuer. */\n subject: string;\n email: string;\n /** The provider's own `email_verified`. Absent counts as false. */\n emailVerified: boolean;\n name: string;\n /** The groups claim, reduced at sign-in to the groups the rules name. */\n groups: string[];\n}\n\nexport interface SessionRecord {\n identity: SessionIdentity;\n csrfToken: string;\n createdAt: string;\n /** When the session was last issued; drives the sliding idle window. */\n refreshedAt: string;\n expiresAt: string;\n}\n\nexport interface SessionService {\n /** The cookie names, which depend on whether the cookies are Secure. */\n readonly names: CookieNames;\n /** Seals a new session. Throws when the identity cannot fit in a cookie. */\n create(identity: SessionIdentity): { record: SessionRecord; cookie: string };\n /** The live session in a cookie, or null for a missing, forged or expired one. */\n resolve(cookieValue: string | undefined): SessionRecord | null;\n /** A later-expiring copy of an active session, or null while it is fresh. */\n refresh(record: SessionRecord): { record: SessionRecord; cookie: string } | null;\n sessionCookieOptions(): CookieOptions;\n csrfCookieOptions(): CookieOptions;\n clearedCookieOptions(): CookieOptions;\n}\n\nexport interface CookieOptions {\n path: string;\n httpOnly: boolean;\n secure: boolean;\n sameSite: \"Lax\" | \"Strict\" | \"None\";\n maxAge: number;\n}\n\n/** Constant-time string comparison that tolerates differing lengths. */\nexport function safeEqual(a: string, b: string): boolean {\n const left = Buffer.from(a);\n const right = Buffer.from(b);\n if (left.length !== right.length) {\n // Still compare, so the branch does not leak the length difference.\n timingSafeEqual(left, left);\n return false;\n }\n return timingSafeEqual(left, right);\n}\n\n/**\n * Keycloak sends `/mica-editors` where the rule says `mica-editors`; both\n * spellings are the same group — see admission.ts.\n */\nconst groupName = (group: string): string => group.replace(/^\\//, \"\");\n\n/** Defensive read of a decrypted payload: it was written by us, but checked anyway. */\nfunction parseRecord(json: string): SessionRecord | null {\n try {\n const value = JSON.parse(json) as Partial<SessionRecord>;\n const identity = value.identity;\n if (\n !identity ||\n typeof identity.issuer !== \"string\" ||\n typeof identity.subject !== \"string\" ||\n typeof value.csrfToken !== \"string\" ||\n typeof value.createdAt !== \"string\" ||\n typeof value.refreshedAt !== \"string\" ||\n typeof value.expiresAt !== \"string\"\n ) {\n return null;\n }\n return {\n identity: {\n issuer: identity.issuer,\n subject: identity.subject,\n email: typeof identity.email === \"string\" ? identity.email : \"\",\n emailVerified: identity.emailVerified === true,\n name: typeof identity.name === \"string\" ? identity.name : \"\",\n groups: Array.isArray(identity.groups)\n ? identity.groups.filter((group): group is string => typeof group === \"string\")\n : [],\n },\n csrfToken: value.csrfToken,\n createdAt: value.createdAt,\n refreshedAt: value.refreshedAt,\n expiresAt: value.expiresAt,\n };\n } catch {\n return null;\n }\n}\n\nexport function createSessionService(config: AuthConfig): SessionService {\n const ttlMs = config.MICA_SESSION_TTL_HOURS * 3600_000;\n const idleMs = config.MICA_SESSION_IDLE_HOURS * 3600_000;\n\n const key = Buffer.from(\n hkdfSync(\"sha256\", config.sessionSecret, \"\", \"mica session cookie v1\", 32),\n );\n\n // Only the groups a rule names are kept: an Entra token can carry hundreds,\n // and a cookie holds 4 KB. A group added to the rules later counts from the\n // next sign-in.\n const admitted = new Set(config.admitGroups.map(groupName));\n\n /** Expiry is the earlier of the idle window and the absolute lifetime. */\n const nextExpiry = (createdAt: number, now: number): string =>\n new Date(Math.min(now + idleMs, createdAt + ttlMs)).toISOString();\n\n const seal = (record: SessionRecord): string => {\n const iv = randomBytes(IV_BYTES);\n const cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n const body = Buffer.concat([cipher.update(JSON.stringify(record), \"utf8\"), cipher.final()]);\n const cookie = Buffer.concat([iv, body, cipher.getAuthTag()]).toString(\"base64url\");\n\n if (cookie.length > MAX_COOKIE_BYTES) {\n throw new Error(\"The session does not fit in a cookie; the identity is too large.\");\n }\n return cookie;\n };\n\n const open = (cookieValue: string): SessionRecord | null => {\n const raw = Buffer.from(cookieValue, \"base64url\");\n if (raw.length <= IV_BYTES + TAG_BYTES) return null;\n\n try {\n const decipher = createDecipheriv(\"aes-256-gcm\", key, raw.subarray(0, IV_BYTES));\n decipher.setAuthTag(raw.subarray(raw.length - TAG_BYTES));\n const json = Buffer.concat([\n decipher.update(raw.subarray(IV_BYTES, raw.length - TAG_BYTES)),\n decipher.final(),\n ]).toString(\"utf8\");\n return parseRecord(json);\n } catch {\n // Forged, truncated, or sealed with a secret that has since rotated.\n return null;\n }\n };\n\n return {\n names: cookieNames(config.cookieSecure),\n\n create(identity) {\n const now = Date.now();\n const record: SessionRecord = {\n identity: {\n ...identity,\n groups: identity.groups.filter((group) => admitted.has(groupName(group))),\n },\n csrfToken: randomToken(32),\n createdAt: new Date(now).toISOString(),\n refreshedAt: new Date(now).toISOString(),\n expiresAt: nextExpiry(now, now),\n };\n return { record, cookie: seal(record) };\n },\n\n resolve(cookieValue) {\n if (!cookieValue) return null;\n const record = open(cookieValue);\n if (!record || Date.parse(record.expiresAt) <= Date.now()) return null;\n return record;\n },\n\n refresh(record) {\n const now = Date.now();\n if (now - Date.parse(record.refreshedAt) < REFRESH_INTERVAL_MS) return null;\n\n const next: SessionRecord = {\n ...record,\n refreshedAt: new Date(now).toISOString(),\n expiresAt: nextExpiry(Date.parse(record.createdAt), now),\n };\n return { record: next, cookie: seal(next) };\n },\n\n sessionCookieOptions() {\n return {\n path: \"/\",\n httpOnly: true,\n secure: config.cookieSecure,\n // Lax keeps the cookie on top-level navigations back to the admin\n // while blocking it on cross-site form posts.\n sameSite: \"Lax\",\n maxAge: Math.floor(ttlMs / 1000),\n };\n },\n\n csrfCookieOptions() {\n return {\n path: \"/\",\n // Readable by the SPA on purpose: it echoes the value in a header,\n // which is what makes the double-submit check work.\n httpOnly: false,\n secure: config.cookieSecure,\n sameSite: \"Lax\",\n maxAge: Math.floor(ttlMs / 1000),\n };\n },\n\n clearedCookieOptions() {\n return {\n path: \"/\",\n httpOnly: true,\n secure: config.cookieSecure,\n sameSite: \"Lax\",\n maxAge: 0,\n };\n },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Starting one\n * ------------------------------------------------------------------ */\n\n/** Issues the session cookies for a browser that has just been identified. */\nexport function establishSession(\n c: Context<Env>,\n sessions: SessionService,\n identity: SessionIdentity,\n): void {\n const { record, cookie } = sessions.create(identity);\n\n setCookie(c, sessions.names.session, cookie, sessions.sessionCookieOptions());\n setCookie(c, sessions.names.csrf, record.csrfToken, sessions.csrfCookieOptions());\n}\n"]}
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@micacms/auth-oidc",
3
- "version": "0.5.0-rc.2",
3
+ "version": "0.6.0-rc.1",
4
4
  "type": "module",
5
5
  "description": "The server-only OIDC gate for Mica: sign-in, sessions and admission around @micacms/runtime. Never enters a browser bundle, and never a dependency of the local editor.",
6
6
  "license": "MIT",
7
+ "homepage": "https://micacms.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/micacms/mica.git",
11
+ "directory": "packages/auth/oidc"
12
+ },
13
+ "bugs": "https://github.com/micacms/mica/issues",
7
14
  "author": "Alexander Müdespacher",
8
15
  "keywords": [
9
16
  "mica",
@@ -22,14 +29,15 @@
22
29
  }
23
30
  },
24
31
  "files": [
25
- "dist"
32
+ "dist",
33
+ "src"
26
34
  ],
27
35
  "dependencies": {
28
36
  "hono": "^4.13.7",
29
37
  "openid-client": "^6.8.5",
30
38
  "zod": "^4.3.6",
31
- "@micacms/core": "0.5.0-rc.2",
32
- "@micacms/runtime": "0.5.0-rc.2"
39
+ "@micacms/core": "0.6.0-rc.1",
40
+ "@micacms/runtime": "0.6.0-rc.1"
33
41
  },
34
42
  "devDependencies": {
35
43
  "@types/node": "^22.20.1",
@@ -37,7 +45,7 @@
37
45
  "typescript": "^5.7.3"
38
46
  },
39
47
  "engines": {
40
- "node": ">=22.12.0"
48
+ "node": ">=24"
41
49
  },
42
50
  "scripts": {
43
51
  "build": "tsc -p tsconfig.json",