@micacms/auth-oidc 0.2.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.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @micacms/auth-oidc
2
+
3
+ > **Experimental.** Pre-1.0: the API can still change without notice.
4
+
5
+ The server-only OIDC gate for Mica: the authorization code flow with PKCE,
6
+ sessions in SQLite, admission rules over provider claims, and the middleware
7
+ gate the runtime mounts in front of the admin API.
8
+
9
+ It is a dependency of `@micacms/self-hosted` and of nothing else. In
10
+ particular, the local editor (`@micacms/editor`) must never gain a path to
11
+ this package — that absence is the product boundary that keeps
12
+ `npx @micacms/editor` free of authentication code, and a test walks the
13
+ dependency graph to enforce it.
14
+
15
+ Mica implements no passwords, no reset flow, no MFA and no passkeys, in here
16
+ or anywhere: an external identity provider does all of it better.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Admission — who may edit this site.
3
+ *
4
+ * OpenID Connect states who a person is. It says nothing about whether that
5
+ * person may edit this website: a provider vouches for a whole company, and a
6
+ * perfectly valid ID token is the beginning of the question rather than the
7
+ * answer to it.
8
+ *
9
+ * Mica used to answer it from a `memberships` table. It no longer has one, and
10
+ * has no accounts to put in it. Admission is configuration:
11
+ *
12
+ * MICA_ADMIT_GROUP=mica-editors a claim the provider sends
13
+ * MICA_ADMIT_DOMAIN=example.com every verified address there
14
+ * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3
15
+ *
16
+ * The group rule is the one to grow into. Adding an editor is then a group
17
+ * change in the identity provider — where the operator already manages
18
+ * joiners and leavers — and Mica is not touched, not restarted, and holds no
19
+ * copy of the list to go stale. The subject list exists for a provider with no
20
+ * groups, and for the first sign-in before a group exists.
21
+ *
22
+ * What this costs, and it is a real cost: removing somebody from a group at
23
+ * the provider does not take effect until their next sign-in, because the
24
+ * claims Mica checks are the ones it was given. Configuration changes *are*
25
+ * immediate — this policy is re-read on every request — and the session
26
+ * lifetime is the upper bound on the rest. That trade is recorded as a P1
27
+ * decision in docs/decisions.md.
28
+ */
29
+ import type { AuthConfig } from "./config.js";
30
+ import type { SessionIdentity } from "./sessions-repository.js";
31
+ export type AdmissionResult = {
32
+ admitted: true;
33
+ } | {
34
+ admitted: false;
35
+ reason: string;
36
+ };
37
+ export interface AdmissionPolicy {
38
+ /** One line for the startup log, so an operator can see what is in force. */
39
+ readonly summary: string;
40
+ admits(identity: SessionIdentity): AdmissionResult;
41
+ }
42
+ export declare function createAdmissionPolicy(config: Pick<AuthConfig, "admitGroups" | "admitDomains" | "admitSubjects">): AdmissionPolicy;
43
+ //# sourceMappingURL=admission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admission.d.ts","sourceRoot":"","sources":["../src/admission.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,MAAM,MAAM,eAAe,GAAG;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvF,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,QAAQ,EAAE,eAAe,GAAG,eAAe,CAAC;CACpD;AAsCD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,aAAa,GAAG,cAAc,GAAG,eAAe,CAAC,GACzE,eAAe,CA+CjB"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Admission — who may edit this site.
3
+ *
4
+ * OpenID Connect states who a person is. It says nothing about whether that
5
+ * person may edit this website: a provider vouches for a whole company, and a
6
+ * perfectly valid ID token is the beginning of the question rather than the
7
+ * answer to it.
8
+ *
9
+ * Mica used to answer it from a `memberships` table. It no longer has one, and
10
+ * has no accounts to put in it. Admission is configuration:
11
+ *
12
+ * MICA_ADMIT_GROUP=mica-editors a claim the provider sends
13
+ * MICA_ADMIT_DOMAIN=example.com every verified address there
14
+ * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3
15
+ *
16
+ * The group rule is the one to grow into. Adding an editor is then a group
17
+ * change in the identity provider — where the operator already manages
18
+ * joiners and leavers — and Mica is not touched, not restarted, and holds no
19
+ * copy of the list to go stale. The subject list exists for a provider with no
20
+ * groups, and for the first sign-in before a group exists.
21
+ *
22
+ * What this costs, and it is a real cost: removing somebody from a group at
23
+ * the provider does not take effect until their next sign-in, because the
24
+ * claims Mica checks are the ones it was given. Configuration changes *are*
25
+ * immediate — this policy is re-read on every request — and the session
26
+ * lifetime is the upper bound on the rest. That trade is recorded as a P1
27
+ * decision in docs/decisions.md.
28
+ */
29
+ /**
30
+ * The subject is the account key, and an email address is never one.
31
+ *
32
+ * Most providers let a person change their address, and some let two people
33
+ * hold one in turn. Keying admission on an address is therefore a standing
34
+ * offer to whoever holds it next, which is a well-trodden route to account
35
+ * takeover. The issuer is part of the key because a subject is only unique
36
+ * within the provider that issued it.
37
+ */
38
+ function matchesSubject(identity, entries) {
39
+ return entries.some((entry) => entry.issuer === identity.issuer && entry.subject === identity.subject);
40
+ }
41
+ function matchesDomain(identity, domains) {
42
+ const at = identity.email.lastIndexOf("@");
43
+ if (at < 0)
44
+ return false;
45
+ return domains.includes(identity.email.slice(at + 1).toLowerCase());
46
+ }
47
+ /**
48
+ * Group names are compared case-sensitively, and a leading `/` is ignored.
49
+ *
50
+ * Keycloak sends group paths — `/mica-editors` — while most other providers
51
+ * send bare names. Accepting both means an operator writes the name they see
52
+ * in their own console, which is the only name they have.
53
+ */
54
+ function matchesGroup(identity, groups) {
55
+ const held = new Set(identity.groups.map((group) => group.replace(/^\//, "")));
56
+ return groups.some((group) => held.has(group.replace(/^\//, "")));
57
+ }
58
+ export function createAdmissionPolicy(config) {
59
+ const { admitGroups, admitDomains, admitSubjects } = config;
60
+ const summary = [
61
+ admitGroups.length > 0 ? `group=${admitGroups.join("|")}` : null,
62
+ admitDomains.length > 0 ? `domain=${admitDomains.join("|")}` : null,
63
+ admitSubjects.length > 0 ? `subjects=${admitSubjects.length}` : null,
64
+ ]
65
+ .filter(Boolean)
66
+ .join(", ");
67
+ return {
68
+ summary,
69
+ admits(identity) {
70
+ // The explicit list first: it is the escape hatch, and it has to work
71
+ // when the group claim is missing or the address is unverified.
72
+ if (matchesSubject(identity, admitSubjects))
73
+ return { admitted: true };
74
+ if (matchesGroup(identity, admitGroups))
75
+ return { admitted: true };
76
+ if (admitDomains.length > 0 && matchesDomain(identity, admitDomains)) {
77
+ /**
78
+ * A domain rule trusts the provider to have checked the address.
79
+ *
80
+ * Where a provider allows self-registration, an unverified address is
81
+ * a claim the person typed. Admitting on that would let anybody enter
82
+ * a domain they do not hold, so the rule refuses and says why — the
83
+ * operator can turn verification on, or use a group or subject rule
84
+ * instead.
85
+ */
86
+ if (identity.emailVerified)
87
+ return { admitted: true };
88
+ return {
89
+ admitted: false,
90
+ reason: `${identity.email} is in an admitted domain, but the identity provider ` +
91
+ "does not report the address as verified.",
92
+ };
93
+ }
94
+ return {
95
+ admitted: false,
96
+ reason: `${identity.email || identity.subject} is not admitted to this site.`,
97
+ };
98
+ },
99
+ };
100
+ }
101
+ //# sourceMappingURL=admission.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admission.js","sourceRoot":"","sources":["../src/admission.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAaH;;;;;;;;GAQG;AACH,SAAS,cAAc,CACrB,QAAyB,EACzB,OAA2D;IAE3D,OAAO,OAAO,CAAC,IAAI,CACjB,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,IAAI,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC,OAAO,CAClF,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,QAAyB,EAAE,OAA0B;IAC1E,MAAM,EAAE,GAAG,QAAQ,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC3C,IAAI,EAAE,GAAG,CAAC;QAAE,OAAO,KAAK,CAAC;IACzB,OAAO,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY,CAAC,QAAyB,EAAE,MAAyB;IACxE,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/E,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,MAA0E;IAE1E,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,GAAG,MAAM,CAAC;IAE5D,MAAM,OAAO,GAAG;QACd,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI;QAChE,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI;QACnE,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,aAAa,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI;KACrE;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,OAAO;QACL,OAAO;QAEP,MAAM,CAAC,QAAQ;YACb,sEAAsE;YACtE,gEAAgE;YAChE,IAAI,cAAc,CAAC,QAAQ,EAAE,aAAa,CAAC;gBAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YAEvE,IAAI,YAAY,CAAC,QAAQ,EAAE,WAAW,CAAC;gBAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YAEnE,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,IAAI,aAAa,CAAC,QAAQ,EAAE,YAAY,CAAC,EAAE,CAAC;gBACrE;;;;;;;;mBAQG;gBACH,IAAI,QAAQ,CAAC,aAAa;oBAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;gBAEtD,OAAO;oBACL,QAAQ,EAAE,KAAK;oBACf,MAAM,EACJ,GAAG,QAAQ,CAAC,KAAK,uDAAuD;wBACxE,0CAA0C;iBAC7C,CAAC;YACJ,CAAC;YAED,OAAO;gBACL,QAAQ,EAAE,KAAK;gBACf,MAAM,EAAE,GAAG,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,OAAO,gCAAgC;aAC9E,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The auth gate's slice of the environment.
3
+ *
4
+ * A fragment, like the runtime's: the self-hosted app spreads `authEnv` into
5
+ * its own schema, derives with `deriveAuth`, and passes the result here. The
6
+ * local editor never touches any of this — these variables do not exist in
7
+ * its configuration at all, which is a stronger statement than ignoring them.
8
+ */
9
+ import { z } from "zod";
10
+ export declare const authEnv: {
11
+ /**
12
+ * OpenID Connect. Mica implements no passwords, no reset flow and no second
13
+ * factor. It asks a provider who you are; the admission rules below decide
14
+ * whether that person may edit this site.
15
+ */
16
+ MICA_OIDC_ISSUER: z.ZodOptional<z.ZodString>;
17
+ MICA_OIDC_CLIENT_ID: z.ZodOptional<z.ZodString>;
18
+ /** Omit for a public client using PKCE alone. */
19
+ MICA_OIDC_CLIENT_SECRET: z.ZodOptional<z.ZodString>;
20
+ MICA_OIDC_SCOPE: z.ZodDefault<z.ZodString>;
21
+ /**
22
+ * Which claim carries the person's groups. There is no standard one:
23
+ * Keycloak and Entra normally send `groups`, others send `roles`, and some
24
+ * send whatever the operator mapped. Naming it here is what keeps
25
+ * `MICA_ADMIT_GROUP` working against any provider.
26
+ */
27
+ MICA_OIDC_GROUPS_CLAIM: z.ZodDefault<z.ZodString>;
28
+ /**
29
+ * Who may edit this site. Authentication says who somebody is; these say
30
+ * whether that person gets in. At least one is required — the composition
31
+ * refuses to start without one. Comma-separated, all three.
32
+ */
33
+ MICA_ADMIT_GROUP: z.ZodOptional<z.ZodString>;
34
+ MICA_ADMIT_DOMAIN: z.ZodOptional<z.ZodString>;
35
+ /** `issuer|subject` pairs. Never an email — see admission.ts. */
36
+ MICA_ADMIT_SUBJECTS: z.ZodOptional<z.ZodString>;
37
+ /** Signs session cookies. Rotating it invalidates every session. */
38
+ SESSION_SECRET: z.ZodOptional<z.ZodString>;
39
+ /**
40
+ * Deliberately short. Admission is configuration and is re-read on every
41
+ * request; what cannot be immediate is a change *at the provider*, which
42
+ * Mica only sees at the next sign-in. The session lifetime is therefore the
43
+ * upper bound on how long a revoked editor keeps working.
44
+ */
45
+ SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
46
+ SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
47
+ LOGIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
48
+ LOGIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
49
+ };
50
+ declare const authSchema: z.ZodObject<{
51
+ MICA_OIDC_ISSUER: z.ZodOptional<z.ZodString>;
52
+ MICA_OIDC_CLIENT_ID: z.ZodOptional<z.ZodString>;
53
+ MICA_OIDC_CLIENT_SECRET: z.ZodOptional<z.ZodString>;
54
+ MICA_OIDC_SCOPE: z.ZodDefault<z.ZodString>;
55
+ MICA_OIDC_GROUPS_CLAIM: z.ZodDefault<z.ZodString>;
56
+ MICA_ADMIT_GROUP: z.ZodOptional<z.ZodString>;
57
+ MICA_ADMIT_DOMAIN: z.ZodOptional<z.ZodString>;
58
+ MICA_ADMIT_SUBJECTS: z.ZodOptional<z.ZodString>;
59
+ SESSION_SECRET: z.ZodOptional<z.ZodString>;
60
+ SESSION_TTL_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
61
+ SESSION_IDLE_HOURS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
62
+ LOGIN_MAX_ATTEMPTS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
63
+ LOGIN_WINDOW_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
64
+ }, z.core.$strip>;
65
+ export type AuthEnv = z.infer<typeof authSchema>;
66
+ /** The derived values the gate reads, on top of the parsed environment. */
67
+ export declare function deriveAuth<T extends AuthEnv & {
68
+ NODE_ENV: string;
69
+ MICA_PUBLIC_URL: string;
70
+ }>(env: T): T & {
71
+ /**
72
+ * Discovery over plain HTTP, for an identity provider running on
73
+ * localhost. Never in production, and never for a remote host.
74
+ */
75
+ oidcAllowInsecure: boolean;
76
+ admitGroups: string[];
77
+ admitDomains: string[];
78
+ admitSubjects: {
79
+ issuer: string;
80
+ subject: string;
81
+ }[];
82
+ cookieSecure: boolean;
83
+ };
84
+ /** What the gate requires of a finished configuration. */
85
+ export interface AuthConfig {
86
+ MICA_PUBLIC_URL: string;
87
+ isProduction: boolean;
88
+ MICA_OIDC_ISSUER?: string | undefined;
89
+ MICA_OIDC_CLIENT_ID?: string | undefined;
90
+ MICA_OIDC_CLIENT_SECRET?: string | undefined;
91
+ MICA_OIDC_SCOPE: string;
92
+ MICA_OIDC_GROUPS_CLAIM: string;
93
+ oidcAllowInsecure: boolean;
94
+ admitGroups: string[];
95
+ admitDomains: string[];
96
+ admitSubjects: Array<{
97
+ issuer: string;
98
+ subject: string;
99
+ }>;
100
+ /** Resolved by the app: mandatory in production, ephemeral in development. */
101
+ sessionSecret: string;
102
+ cookieSecure: boolean;
103
+ SESSION_TTL_HOURS: number;
104
+ SESSION_IDLE_HOURS: number;
105
+ LOGIN_MAX_ATTEMPTS: number;
106
+ LOGIN_WINDOW_MS: number;
107
+ }
108
+ /**
109
+ * The refusals that make a deployment honest before it serves a request.
110
+ *
111
+ * An OIDC deployment that admits every person its issuer knows is a
112
+ * misconfiguration, not a default — see decisions.md.
113
+ */
114
+ export declare function requireAuthConfig(config: {
115
+ MICA_PUBLIC_URL: string;
116
+ MICA_OIDC_ISSUER?: string | undefined;
117
+ MICA_OIDC_CLIENT_ID?: string | undefined;
118
+ admitGroups: string[];
119
+ admitDomains: string[];
120
+ admitSubjects: Array<{
121
+ issuer: string;
122
+ subject: string;
123
+ }>;
124
+ }): void;
125
+ export {};
126
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +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,iEAAiE;;IAGjE,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;;;;;;;;;EAqBN;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,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;AAED;;;;;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,aAAa,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3D,GAAG,IAAI,CA2CP"}
package/dist/config.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The auth gate's slice of the environment.
3
+ *
4
+ * A fragment, like the runtime's: the self-hosted app spreads `authEnv` into
5
+ * its own schema, derives with `deriveAuth`, and passes the result here. The
6
+ * local editor never touches any of this — these variables do not exist in
7
+ * its configuration at all, which is a stronger statement than ignoring them.
8
+ */
9
+ import { z } from "zod";
10
+ import { ConfigError, list } from "@micacms/runtime";
11
+ export const authEnv = {
12
+ /**
13
+ * OpenID Connect. Mica implements no passwords, no reset flow and no second
14
+ * factor. It asks a provider who you are; the admission rules below decide
15
+ * whether that person may edit this site.
16
+ */
17
+ MICA_OIDC_ISSUER: z.string().url().optional(),
18
+ MICA_OIDC_CLIENT_ID: z.string().optional(),
19
+ /** Omit for a public client using PKCE alone. */
20
+ MICA_OIDC_CLIENT_SECRET: z.string().optional(),
21
+ MICA_OIDC_SCOPE: z.string().default("openid profile email"),
22
+ /**
23
+ * Which claim carries the person's groups. There is no standard one:
24
+ * Keycloak and Entra normally send `groups`, others send `roles`, and some
25
+ * send whatever the operator mapped. Naming it here is what keeps
26
+ * `MICA_ADMIT_GROUP` working against any provider.
27
+ */
28
+ MICA_OIDC_GROUPS_CLAIM: z.string().default("groups"),
29
+ /**
30
+ * Who may edit this site. Authentication says who somebody is; these say
31
+ * whether that person gets in. At least one is required — the composition
32
+ * refuses to start without one. Comma-separated, all three.
33
+ */
34
+ MICA_ADMIT_GROUP: z.string().optional(),
35
+ MICA_ADMIT_DOMAIN: z.string().optional(),
36
+ /** `issuer|subject` pairs. Never an email — see admission.ts. */
37
+ MICA_ADMIT_SUBJECTS: z.string().optional(),
38
+ /** Signs session cookies. Rotating it invalidates every session. */
39
+ SESSION_SECRET: z.string().min(32).optional(),
40
+ /**
41
+ * Deliberately short. Admission is configuration and is re-read on every
42
+ * request; what cannot be immediate is a change *at the provider*, which
43
+ * Mica only sees at the next sign-in. The session lifetime is therefore the
44
+ * upper bound on how long a revoked editor keeps working.
45
+ */
46
+ SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),
47
+ SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),
48
+ LOGIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),
49
+ LOGIN_WINDOW_MS: z.coerce
50
+ .number()
51
+ .int()
52
+ .positive()
53
+ .default(15 * 60 * 1000),
54
+ };
55
+ const authSchema = z.object(authEnv);
56
+ /** The derived values the gate reads, on top of the parsed environment. */
57
+ export function deriveAuth(env) {
58
+ return {
59
+ ...env,
60
+ /**
61
+ * Discovery over plain HTTP, for an identity provider running on
62
+ * localhost. Never in production, and never for a remote host.
63
+ */
64
+ oidcAllowInsecure: env.NODE_ENV !== "production" &&
65
+ /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(env.MICA_OIDC_ISSUER ?? ""),
66
+ admitGroups: list(env.MICA_ADMIT_GROUP),
67
+ // Lowercased: a domain is case-insensitive, and an operator who writes
68
+ // `Example.com` means the same thing the provider spells `example.com`.
69
+ admitDomains: list(env.MICA_ADMIT_DOMAIN).map((domain) => domain.toLowerCase().replace(/^@/, "")),
70
+ admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {
71
+ const separator = entry.indexOf("|");
72
+ return {
73
+ issuer: separator < 0 ? "" : entry.slice(0, separator).trim(),
74
+ subject: separator < 0 ? entry : entry.slice(separator + 1).trim(),
75
+ };
76
+ }),
77
+ // Secure cookies are not sent over plain HTTP, which would break a
78
+ // localhost trial run.
79
+ cookieSecure: env.MICA_PUBLIC_URL.startsWith("https://"),
80
+ };
81
+ }
82
+ /**
83
+ * The refusals that make a deployment honest before it serves a request.
84
+ *
85
+ * An OIDC deployment that admits every person its issuer knows is a
86
+ * misconfiguration, not a default — see decisions.md.
87
+ */
88
+ export function requireAuthConfig(config) {
89
+ const needed = ["MICA_OIDC_ISSUER", "MICA_OIDC_CLIENT_ID"].filter((key) => !config[key]);
90
+ if (needed.length > 0) {
91
+ throw new ConfigError(`This deployment authenticates with OpenID Connect, so ` +
92
+ `${needed.join(" and ")} must be set.\n` +
93
+ " Mica implements no passwords. Point it at Keycloak, Authentik,\n" +
94
+ " ZITADEL, Entra, Okta, Google — anything that speaks OIDC.\n" +
95
+ ` The redirect URI to register is ${config.MICA_PUBLIC_URL}/api/auth/callback`);
96
+ }
97
+ if (config.admitGroups.length === 0 &&
98
+ config.admitDomains.length === 0 &&
99
+ config.admitSubjects.length === 0) {
100
+ throw new ConfigError("Nothing says who may edit this site.\n" +
101
+ " Authentication is not admission: your provider will happily vouch for\n" +
102
+ " everybody it knows. Set at least one of these:\n\n" +
103
+ " MICA_ADMIT_GROUP=mica-editors a claim your provider sends\n" +
104
+ " MICA_ADMIT_DOMAIN=example.com every verified address there\n" +
105
+ " MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n\n" +
106
+ " The group rule is the one to grow into: adding an editor is then a\n" +
107
+ " change in the identity provider, and Mica is not touched.");
108
+ }
109
+ const wrong = config.admitSubjects.filter((entry) => !entry.issuer || !entry.subject);
110
+ if (wrong.length > 0) {
111
+ throw new ConfigError("MICA_ADMIT_SUBJECTS entries are `issuer|subject` pairs, and one is not:\n" +
112
+ ` ${wrong.map((entry) => entry.subject || "(empty)").join(", ")}\n\n` +
113
+ " The issuer is part of the key because a subject is only unique within\n" +
114
+ " the provider that issued it. It is never an email address: providers\n" +
115
+ " let people change those, and two people can hold one in turn.\n" +
116
+ ` MICA_ADMIT_SUBJECTS=${config.MICA_OIDC_ISSUER ?? "https://id.example.com"}|a1b2c3`);
117
+ }
118
+ }
119
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/gate.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The gate: what stands between a request and the admin API.
3
+ *
4
+ * Two layers, in order. `requireAccess` turns a session cookie into an
5
+ * `AccessContext` — or a 401 — re-checking admission on every request so a
6
+ * configuration change is immediate. `csrfProtection` is the double-submit
7
+ * token over the Origin check the runtime already applies: cross-origin
8
+ * JavaScript can neither read the cookie nor set the header.
9
+ */
10
+ import type { Context, MiddlewareHandler } from "hono";
11
+ import { type Env } from "@micacms/runtime";
12
+ import type { AdmissionPolicy } from "./admission.js";
13
+ import { type SessionService } from "./session.js";
14
+ import type { SessionRecord, SessionRepository } from "./sessions-repository.js";
15
+ export declare function sessionOf(c: Context<Env>): SessionRecord;
16
+ export interface GateDeps {
17
+ sessions: SessionService;
18
+ sessionsRepo: SessionRepository;
19
+ admission: AdmissionPolicy;
20
+ }
21
+ /**
22
+ * Rejects the request unless there is a live session whose person is still
23
+ * admitted.
24
+ *
25
+ * Admission is re-read on every request, not decided once at sign-in. That is
26
+ * what makes a configuration change immediate: an operator who takes somebody
27
+ * out of `MICA_ADMIT_SUBJECTS` and restarts has removed them. What it cannot
28
+ * make immediate is a group removal at the provider — the claims here are the
29
+ * ones the provider sent, and Mica learns better at the next sign-in.
30
+ */
31
+ export declare function requireAccess(deps: GateDeps): MiddlewareHandler<Env>;
32
+ /**
33
+ * Second CSRF layer: double-submit token. The session's token is issued in a
34
+ * cookie the SPA can read and must be echoed in a header.
35
+ *
36
+ * Runs after `requireAccess`, and complements the session cookie's
37
+ * `SameSite=Lax`, which already blocks the classic cross-site form post.
38
+ */
39
+ export declare function csrfProtection(): MiddlewareHandler<Env>;
40
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAEvD,OAAO,EAAY,KAAK,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAuD,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACxG,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAQjF,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,GAAG,aAAa,CAGxD;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,cAAc,CAAC;IACzB,YAAY,EAAE,iBAAiB,CAAC;IAChC,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAgCpE;AAID;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAiBvD"}
package/dist/gate.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The gate: what stands between a request and the admin API.
3
+ *
4
+ * Two layers, in order. `requireAccess` turns a session cookie into an
5
+ * `AccessContext` — or a 401 — re-checking admission on every request so a
6
+ * configuration change is immediate. `csrfProtection` is the double-submit
7
+ * token over the Origin check the runtime already applies: cross-origin
8
+ * JavaScript can neither read the cookie nor set the header.
9
+ */
10
+ import { getCookie } from "hono/cookie";
11
+ import { ApiError } from "@micacms/runtime";
12
+ import { CSRF_COOKIE, CSRF_HEADER, safeEqual, SESSION_COOKIE } from "./session.js";
13
+ /**
14
+ * The session behind the current request. Auth's own context variable —
15
+ * the runtime knows only the `access` it derives from it.
16
+ */
17
+ const SESSION_VARIABLE = "authSession";
18
+ export function sessionOf(c) {
19
+ // Set by requireAccess, which runs in front of everything that calls this.
20
+ return c.get(SESSION_VARIABLE);
21
+ }
22
+ /**
23
+ * Rejects the request unless there is a live session whose person is still
24
+ * admitted.
25
+ *
26
+ * Admission is re-read on every request, not decided once at sign-in. That is
27
+ * what makes a configuration change immediate: an operator who takes somebody
28
+ * out of `MICA_ADMIT_SUBJECTS` and restarts has removed them. What it cannot
29
+ * make immediate is a group removal at the provider — the claims here are the
30
+ * ones the provider sent, and Mica learns better at the next sign-in.
31
+ */
32
+ export function requireAccess(deps) {
33
+ return async (c, next) => {
34
+ const session = deps.sessions.resolve(getCookie(c, SESSION_COOKIE));
35
+ if (!session) {
36
+ throw new ApiError("unauthenticated", "Your session has expired. Please sign in again.");
37
+ }
38
+ const { identity } = session;
39
+ if (!identity) {
40
+ // A row from a pre-split local-mode run of the same database. It
41
+ // authenticates nobody, so it is not a session for an authenticated
42
+ // deployment.
43
+ deps.sessionsRepo.delete(session.id);
44
+ throw new ApiError("unauthenticated", "Please sign in again.");
45
+ }
46
+ const verdict = deps.admission.admits(identity);
47
+ if (!verdict.admitted) {
48
+ c.get("logger").warn("Session no longer admitted", {
49
+ subject: identity.subject,
50
+ reason: verdict.reason,
51
+ });
52
+ throw new ApiError("forbidden", "You no longer have access to this site.");
53
+ }
54
+ c.set(SESSION_VARIABLE, session);
55
+ c.set("access", {
56
+ identity: { subject: identity.subject, email: identity.email, name: identity.name },
57
+ });
58
+ await next();
59
+ };
60
+ }
61
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
62
+ /**
63
+ * Second CSRF layer: double-submit token. The session's token is issued in a
64
+ * cookie the SPA can read and must be echoed in a header.
65
+ *
66
+ * Runs after `requireAccess`, and complements the session cookie's
67
+ * `SameSite=Lax`, which already blocks the classic cross-site form post.
68
+ */
69
+ export function csrfProtection() {
70
+ return async (c, next) => {
71
+ if (SAFE_METHODS.has(c.req.method)) {
72
+ await next();
73
+ return;
74
+ }
75
+ const session = sessionOf(c);
76
+ const header = c.req.header(CSRF_HEADER);
77
+ const cookie = getCookie(c, CSRF_COOKIE);
78
+ if (!header || !cookie || !safeEqual(header, cookie) || !safeEqual(header, session.csrfToken)) {
79
+ throw new ApiError("forbidden", "Invalid or missing CSRF token. Reload the page and retry.");
80
+ }
81
+ await next();
82
+ };
83
+ }
84
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.
3
+ *
4
+ * One factory: hand it the finished configuration, an open database and a
5
+ * logger, and it returns the `AuthSurface` the runtime mounts — sign-in
6
+ * routes at `/api/auth`, and the session-plus-CSRF gate in front of the admin
7
+ * API — together with the services the composing app reports on.
8
+ *
9
+ * This package is a dependency of `@micacms/self-hosted` and of nothing else.
10
+ * The local editor must never gain a path to it: that absence is the product
11
+ * boundary that keeps `npx @micacms/editor` free of authentication code.
12
+ */
13
+ import type { AuthSurface, Logger, SqlDatabase } from "@micacms/runtime";
14
+ import { type RateLimiter } from "@micacms/runtime";
15
+ import { type AdmissionPolicy } from "./admission.js";
16
+ import type { AuthConfig } from "./config.js";
17
+ import { type OidcService } from "./oidc.js";
18
+ import { type SessionService } from "./session.js";
19
+ import { type SessionRepository } from "./sessions-repository.js";
20
+ export interface OidcAuthDeps {
21
+ config: AuthConfig;
22
+ /** The database sessions live in. Owned and opened by the composing app. */
23
+ db: SqlDatabase;
24
+ logger: Logger;
25
+ }
26
+ export interface OidcAuth {
27
+ /** What `createApp` mounts: routes at /api/auth, the gate before /api/admin. */
28
+ surface: AuthSurface;
29
+ sessions: SessionService;
30
+ sessionsRepo: SessionRepository;
31
+ admission: AdmissionPolicy;
32
+ oidc: OidcService;
33
+ loginLimiter: RateLimiter;
34
+ }
35
+ export declare function createOidcAuth(deps: OidcAuthDeps): OidcAuth;
36
+ export { createAdmissionPolicy, type AdmissionPolicy, type AdmissionResult } from "./admission.js";
37
+ export { authEnv, deriveAuth, requireAuthConfig, type AuthConfig, type AuthEnv, } from "./config.js";
38
+ export { csrfProtection, requireAccess, sessionOf } from "./gate.js";
39
+ export { createOidcService, type OidcService } from "./oidc.js";
40
+ export { createSessionService, CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE, type SessionService, } from "./session.js";
41
+ export { createSessionRepository, type SessionIdentity, type SessionRecord, type SessionRepository, } from "./sessions-repository.js";
42
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEvE,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAEhE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAA2B,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE3F,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,UAAU,CAAC;IACnB,4EAA4E;IAC5E,EAAE,EAAE,WAAW,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,gFAAgF;IAChF,OAAO,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,cAAc,CAAC;IACzB,YAAY,EAAE,iBAAiB,CAAC;IAChC,SAAS,EAAE,eAAe,CAAC;IAC3B,IAAI,EAAE,WAAW,CAAC;IAClB,YAAY,EAAE,WAAW,CAAC;CAC3B;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAyB3D;AAED,OAAO,EAAE,qBAAqB,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACnG,OAAO,EACL,OAAO,EACP,UAAU,EACV,iBAAiB,EACjB,KAAK,UAAU,EACf,KAAK,OAAO,GACb,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,cAAc,EACd,KAAK,cAAc,GACpB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,uBAAuB,EACvB,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,iBAAiB,GACvB,MAAM,0BAA0B,CAAC"}