@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 +3 -1
- package/dist/admission.d.ts +2 -2
- package/dist/admission.js +2 -2
- package/dist/admission.js.map +1 -1
- package/dist/config.d.ts +19 -19
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -13
- package/dist/config.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +2 -2
- package/dist/session.js +4 -4
- package/dist/session.js.map +1 -1
- package/package.json +13 -5
- package/src/admission.ts +132 -0
- package/src/config.ts +221 -0
- package/src/gate.ts +112 -0
- package/src/index.ts +82 -0
- package/src/oidc.ts +314 -0
- package/src/return-to.ts +21 -0
- package/src/routes.ts +196 -0
- package/src/session.ts +300 -0
package/src/admission.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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 has no accounts, so admission is configuration:
|
|
10
|
+
*
|
|
11
|
+
* MICA_ADMIT_GROUPS=mica-editors a claim the provider sends
|
|
12
|
+
* MICA_ADMIT_DOMAINS=example.com every verified address there
|
|
13
|
+
* MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses
|
|
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 email list exists for a provider with no
|
|
20
|
+
* groups, such as Google; the subject list for one that verifies no addresses.
|
|
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. docs/decisions.md records the trade.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { AuthConfig } from "./config.js";
|
|
30
|
+
import type { SessionIdentity } from "./session.js";
|
|
31
|
+
|
|
32
|
+
export type AdmissionResult = { admitted: true } | { admitted: false; reason: string };
|
|
33
|
+
|
|
34
|
+
export interface AdmissionPolicy {
|
|
35
|
+
/** One line for the startup log, so an operator can see what is in force. */
|
|
36
|
+
readonly summary: string;
|
|
37
|
+
admits(identity: SessionIdentity): AdmissionResult;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The strictest rule: the subject never changes and is never reassigned. The
|
|
42
|
+
* issuer is part of the key because a subject is only unique within the
|
|
43
|
+
* provider that issued it.
|
|
44
|
+
*/
|
|
45
|
+
function matchesSubject(
|
|
46
|
+
identity: SessionIdentity,
|
|
47
|
+
entries: ReadonlyArray<{ issuer: string; subject: string }>,
|
|
48
|
+
): boolean {
|
|
49
|
+
return entries.some(
|
|
50
|
+
(entry) => entry.issuer === identity.issuer && entry.subject === identity.subject,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchesEmail(identity: SessionIdentity, emails: readonly string[]): boolean {
|
|
55
|
+
return emails.includes(identity.email.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesDomain(identity: SessionIdentity, domains: readonly string[]): boolean {
|
|
59
|
+
const at = identity.email.lastIndexOf("@");
|
|
60
|
+
if (at < 0) return false;
|
|
61
|
+
return domains.includes(identity.email.slice(at + 1).toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Group names are compared case-sensitively, and a leading `/` is ignored.
|
|
66
|
+
*
|
|
67
|
+
* Keycloak sends group paths — `/mica-editors` — while most other providers
|
|
68
|
+
* send bare names. Accepting both means an operator writes the name they see
|
|
69
|
+
* in their own console, which is the only name they have.
|
|
70
|
+
*/
|
|
71
|
+
function matchesGroup(identity: SessionIdentity, groups: readonly string[]): boolean {
|
|
72
|
+
const held = new Set(identity.groups.map((group) => group.replace(/^\//, "")));
|
|
73
|
+
return groups.some((group) => held.has(group.replace(/^\//, "")));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createAdmissionPolicy(
|
|
77
|
+
config: Pick<AuthConfig, "admitGroups" | "admitDomains" | "admitEmails" | "admitSubjects">,
|
|
78
|
+
): AdmissionPolicy {
|
|
79
|
+
const { admitGroups, admitDomains, admitEmails, admitSubjects } = config;
|
|
80
|
+
|
|
81
|
+
const summary = [
|
|
82
|
+
admitGroups.length > 0 ? `group=${admitGroups.join("|")}` : null,
|
|
83
|
+
admitDomains.length > 0 ? `domain=${admitDomains.join("|")}` : null,
|
|
84
|
+
admitEmails.length > 0 ? `emails=${admitEmails.length}` : null,
|
|
85
|
+
admitSubjects.length > 0 ? `subjects=${admitSubjects.length}` : null,
|
|
86
|
+
]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(", ");
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
summary,
|
|
92
|
+
|
|
93
|
+
admits(identity) {
|
|
94
|
+
// The explicit list first: it is the escape hatch, and it has to work
|
|
95
|
+
// when the group claim is missing or the address is unverified.
|
|
96
|
+
if (matchesSubject(identity, admitSubjects)) return { admitted: true };
|
|
97
|
+
|
|
98
|
+
if (matchesGroup(identity, admitGroups)) return { admitted: true };
|
|
99
|
+
|
|
100
|
+
const byEmail = matchesEmail(identity, admitEmails);
|
|
101
|
+
if (byEmail || matchesDomain(identity, admitDomains)) {
|
|
102
|
+
/**
|
|
103
|
+
* An email or domain rule trusts the provider to have checked the address.
|
|
104
|
+
*
|
|
105
|
+
* Where a provider allows self-registration, an unverified address is
|
|
106
|
+
* a claim the person typed. Admitting on that would let anybody enter
|
|
107
|
+
* an address they do not hold, so the rule refuses and says why — the
|
|
108
|
+
* operator can turn verification on, or use a group or subject rule
|
|
109
|
+
* instead.
|
|
110
|
+
*
|
|
111
|
+
* What remains is reassignment: an organisation that gives a former
|
|
112
|
+
* editor's address to somebody new gives them this site too. Public
|
|
113
|
+
* providers such as Gmail never reuse an address; the trade is
|
|
114
|
+
* recorded in docs/decisions.md.
|
|
115
|
+
*/
|
|
116
|
+
if (identity.emailVerified) return { admitted: true };
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
admitted: false,
|
|
120
|
+
reason:
|
|
121
|
+
`${identity.email} is ${byEmail ? "an admitted address" : "in an admitted domain"}, ` +
|
|
122
|
+
"but the identity provider does not report the address as verified.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
admitted: false,
|
|
128
|
+
reason: `${identity.email || identity.subject} is not admitted to this site.`,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { ConfigError, list } from "@micacms/runtime";
|
|
13
|
+
|
|
14
|
+
export const authEnv = {
|
|
15
|
+
/**
|
|
16
|
+
* OpenID Connect. Mica implements no passwords, no reset flow and no second
|
|
17
|
+
* factor. It asks a provider who you are; the admission rules below decide
|
|
18
|
+
* whether that person may edit this site.
|
|
19
|
+
*/
|
|
20
|
+
MICA_OIDC_ISSUER: z.string().url().optional(),
|
|
21
|
+
MICA_OIDC_CLIENT_ID: z.string().optional(),
|
|
22
|
+
/** Omit for a public client using PKCE alone. */
|
|
23
|
+
MICA_OIDC_CLIENT_SECRET: z.string().optional(),
|
|
24
|
+
MICA_OIDC_SCOPE: z.string().default("openid profile email"),
|
|
25
|
+
/**
|
|
26
|
+
* Which claim carries the person's groups. There is no standard one:
|
|
27
|
+
* Keycloak and Entra normally send `groups`, others send `roles`, and some
|
|
28
|
+
* send whatever the operator mapped. Naming it here is what keeps
|
|
29
|
+
* `MICA_ADMIT_GROUPS` working against any provider.
|
|
30
|
+
*/
|
|
31
|
+
MICA_OIDC_GROUPS_CLAIM: z.string().default("groups"),
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Who may edit this site. Authentication says who somebody is; these say
|
|
35
|
+
* whether that person gets in. At least one is required — the composition
|
|
36
|
+
* refuses to start without one. Comma-separated, all four.
|
|
37
|
+
*/
|
|
38
|
+
MICA_ADMIT_GROUPS: z.string().optional(),
|
|
39
|
+
MICA_ADMIT_DOMAINS: z.string().optional(),
|
|
40
|
+
/** Verified addresses — the list for a provider with no groups, such as Google. */
|
|
41
|
+
MICA_ADMIT_EMAILS: z.string().optional(),
|
|
42
|
+
/** `issuer|subject` pairs — see admission.ts. */
|
|
43
|
+
MICA_ADMIT_SUBJECTS: z.string().optional(),
|
|
44
|
+
|
|
45
|
+
/** Signs session cookies. Rotating it invalidates every session. */
|
|
46
|
+
MICA_SESSION_SECRET: z.string().min(32).optional(),
|
|
47
|
+
/**
|
|
48
|
+
* Deliberately short. Admission is configuration and is re-read on every
|
|
49
|
+
* request; what cannot be immediate is a change *at the provider*, which
|
|
50
|
+
* Mica only sees at the next sign-in. The session lifetime is therefore the
|
|
51
|
+
* upper bound on how long a revoked editor keeps working.
|
|
52
|
+
*/
|
|
53
|
+
MICA_SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),
|
|
54
|
+
MICA_SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),
|
|
55
|
+
|
|
56
|
+
MICA_SIGNIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),
|
|
57
|
+
MICA_SIGNIN_WINDOW_MS: z.coerce
|
|
58
|
+
.number()
|
|
59
|
+
.int()
|
|
60
|
+
.positive()
|
|
61
|
+
.default(15 * 60 * 1000),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const authSchema = z.object(authEnv);
|
|
65
|
+
export type AuthEnv = z.infer<typeof authSchema>;
|
|
66
|
+
|
|
67
|
+
/** The derived values the gate reads, on top of the parsed environment. */
|
|
68
|
+
export function deriveAuth<T extends AuthEnv & { NODE_ENV: string; MICA_PUBLIC_URL: string }>(
|
|
69
|
+
env: T,
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
...env,
|
|
73
|
+
/**
|
|
74
|
+
* Discovery over plain HTTP, for an identity provider running on
|
|
75
|
+
* localhost. Never in production, and never for a remote host.
|
|
76
|
+
*/
|
|
77
|
+
oidcAllowInsecure:
|
|
78
|
+
env.NODE_ENV !== "production" &&
|
|
79
|
+
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(env.MICA_OIDC_ISSUER ?? ""),
|
|
80
|
+
admitGroups: list(env.MICA_ADMIT_GROUPS),
|
|
81
|
+
// Lowercased: a domain is case-insensitive, and an operator who writes
|
|
82
|
+
// `Example.com` means the same thing the provider spells `example.com`.
|
|
83
|
+
admitDomains: list(env.MICA_ADMIT_DOMAINS).map((domain) =>
|
|
84
|
+
domain.toLowerCase().replace(/^@/, ""),
|
|
85
|
+
),
|
|
86
|
+
admitEmails: list(env.MICA_ADMIT_EMAILS).map((email) => email.toLowerCase()),
|
|
87
|
+
admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {
|
|
88
|
+
const separator = entry.indexOf("|");
|
|
89
|
+
return {
|
|
90
|
+
issuer: separator < 0 ? "" : entry.slice(0, separator).trim(),
|
|
91
|
+
subject: separator < 0 ? entry : entry.slice(separator + 1).trim(),
|
|
92
|
+
};
|
|
93
|
+
}),
|
|
94
|
+
// Secure cookies are not sent over plain HTTP, which would break a
|
|
95
|
+
// localhost trial run.
|
|
96
|
+
cookieSecure: env.MICA_PUBLIC_URL.startsWith("https://"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the gate requires of a finished configuration. */
|
|
101
|
+
export interface AuthConfig {
|
|
102
|
+
MICA_PUBLIC_URL: string;
|
|
103
|
+
isProduction: boolean;
|
|
104
|
+
MICA_OIDC_ISSUER?: string | undefined;
|
|
105
|
+
MICA_OIDC_CLIENT_ID?: string | undefined;
|
|
106
|
+
MICA_OIDC_CLIENT_SECRET?: string | undefined;
|
|
107
|
+
MICA_OIDC_SCOPE: string;
|
|
108
|
+
MICA_OIDC_GROUPS_CLAIM: string;
|
|
109
|
+
oidcAllowInsecure: boolean;
|
|
110
|
+
admitGroups: string[];
|
|
111
|
+
admitDomains: string[];
|
|
112
|
+
admitEmails: string[];
|
|
113
|
+
admitSubjects: Array<{ issuer: string; subject: string }>;
|
|
114
|
+
/** Resolved by the app: mandatory in production, ephemeral in development. */
|
|
115
|
+
sessionSecret: string;
|
|
116
|
+
cookieSecure: boolean;
|
|
117
|
+
MICA_SESSION_TTL_HOURS: number;
|
|
118
|
+
MICA_SESSION_IDLE_HOURS: number;
|
|
119
|
+
MICA_SIGNIN_MAX_ATTEMPTS: number;
|
|
120
|
+
MICA_SIGNIN_WINDOW_MS: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Domains where anybody can register an address. A domain rule naming one
|
|
125
|
+
* admits the public, so it is refused; name the addresses instead.
|
|
126
|
+
*/
|
|
127
|
+
const PUBLIC_EMAIL_DOMAINS = new Set([
|
|
128
|
+
"gmail.com",
|
|
129
|
+
"googlemail.com",
|
|
130
|
+
"outlook.com",
|
|
131
|
+
"hotmail.com",
|
|
132
|
+
"live.com",
|
|
133
|
+
"msn.com",
|
|
134
|
+
"yahoo.com",
|
|
135
|
+
"icloud.com",
|
|
136
|
+
"me.com",
|
|
137
|
+
"aol.com",
|
|
138
|
+
"proton.me",
|
|
139
|
+
"protonmail.com",
|
|
140
|
+
"gmx.com",
|
|
141
|
+
"gmx.net",
|
|
142
|
+
"gmx.de",
|
|
143
|
+
"gmx.ch",
|
|
144
|
+
"web.de",
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The refusals that make a deployment honest before it serves a request.
|
|
149
|
+
*
|
|
150
|
+
* An OIDC deployment that admits every person its issuer knows is a
|
|
151
|
+
* misconfiguration, not a default — see decisions.md.
|
|
152
|
+
*/
|
|
153
|
+
export function requireAuthConfig(config: {
|
|
154
|
+
MICA_PUBLIC_URL: string;
|
|
155
|
+
MICA_OIDC_ISSUER?: string | undefined;
|
|
156
|
+
MICA_OIDC_CLIENT_ID?: string | undefined;
|
|
157
|
+
admitGroups: string[];
|
|
158
|
+
admitDomains: string[];
|
|
159
|
+
admitEmails: string[];
|
|
160
|
+
admitSubjects: Array<{ issuer: string; subject: string }>;
|
|
161
|
+
}): void {
|
|
162
|
+
const needed = (["MICA_OIDC_ISSUER", "MICA_OIDC_CLIENT_ID"] as const).filter(
|
|
163
|
+
(key) => !config[key],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (needed.length > 0) {
|
|
167
|
+
throw new ConfigError(
|
|
168
|
+
`This deployment authenticates with OpenID Connect, so ` +
|
|
169
|
+
`${needed.join(" and ")} must be set.\n` +
|
|
170
|
+
" Mica implements no passwords. Point it at Keycloak, Authentik,\n" +
|
|
171
|
+
" ZITADEL, Entra, Okta, Google — anything that speaks OIDC.\n" +
|
|
172
|
+
` The redirect URI to register is ${config.MICA_PUBLIC_URL}/api/auth/callback`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
config.admitGroups.length === 0 &&
|
|
178
|
+
config.admitDomains.length === 0 &&
|
|
179
|
+
config.admitEmails.length === 0 &&
|
|
180
|
+
config.admitSubjects.length === 0
|
|
181
|
+
) {
|
|
182
|
+
throw new ConfigError(
|
|
183
|
+
"Nothing says who may edit this site.\n" +
|
|
184
|
+
" Authentication is not admission: your provider will happily vouch for\n" +
|
|
185
|
+
" everybody it knows. Set at least one of these:\n\n" +
|
|
186
|
+
" MICA_ADMIT_GROUPS=mica-editors a claim your provider sends\n" +
|
|
187
|
+
" MICA_ADMIT_DOMAINS=example.com every verified address there\n" +
|
|
188
|
+
" MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\n" +
|
|
189
|
+
" MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n\n" +
|
|
190
|
+
" The group rule is the one to grow into: adding an editor is then a\n" +
|
|
191
|
+
" change in the identity provider, and Mica is not touched.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const open = config.admitDomains.filter((domain) => PUBLIC_EMAIL_DOMAINS.has(domain));
|
|
196
|
+
if (open.length > 0) {
|
|
197
|
+
throw new ConfigError(
|
|
198
|
+
`MICA_ADMIT_DOMAINS names ${open.join(", ")}, where anybody can register an address.\n` +
|
|
199
|
+
" That rule would let the public edit this site. Name the people instead:\n" +
|
|
200
|
+
" MICA_ADMIT_EMAILS=alice@gmail.com,bob@gmail.com",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const malformed = config.admitEmails.filter((email) => !/^[^@\s]+@[^@\s]+$/.test(email));
|
|
205
|
+
if (malformed.length > 0) {
|
|
206
|
+
throw new ConfigError(
|
|
207
|
+
`MICA_ADMIT_EMAILS entries are email addresses, and these are not: ${malformed.join(", ")}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const wrong = config.admitSubjects.filter((entry) => !entry.issuer || !entry.subject);
|
|
212
|
+
if (wrong.length > 0) {
|
|
213
|
+
throw new ConfigError(
|
|
214
|
+
"MICA_ADMIT_SUBJECTS entries are `issuer|subject` pairs, and one is not:\n" +
|
|
215
|
+
` ${wrong.map((entry) => entry.subject || "(empty)").join(", ")}\n\n` +
|
|
216
|
+
" The issuer is part of the key because a subject is only unique within\n" +
|
|
217
|
+
" the provider that issued it. To admit an address, use MICA_ADMIT_EMAILS.\n" +
|
|
218
|
+
` MICA_ADMIT_SUBJECTS=${config.MICA_OIDC_ISSUER ?? "https://id.example.com"}|a1b2c3`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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, and re-issuing the cookie while the
|
|
7
|
+
* session is in use. `csrfProtection` is the double-submit
|
|
8
|
+
* token over the Origin check the runtime already applies: cross-origin
|
|
9
|
+
* JavaScript can neither read the cookie nor set the header.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
13
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
14
|
+
|
|
15
|
+
import { ApiError, type Env } from "@micacms/runtime";
|
|
16
|
+
|
|
17
|
+
import type { AdmissionPolicy } from "./admission.js";
|
|
18
|
+
import {
|
|
19
|
+
CSRF_HEADER,
|
|
20
|
+
safeEqual,
|
|
21
|
+
type SessionRecord,
|
|
22
|
+
type SessionService,
|
|
23
|
+
} from "./session.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The session behind the current request. Auth's own context variable —
|
|
27
|
+
* the runtime knows only the `access` it derives from it.
|
|
28
|
+
*/
|
|
29
|
+
const SESSION_VARIABLE = "authSession";
|
|
30
|
+
|
|
31
|
+
export function sessionOf(c: Context<Env>): SessionRecord {
|
|
32
|
+
// Set by requireAccess, which runs in front of everything that calls this.
|
|
33
|
+
return c.get(SESSION_VARIABLE as never) as SessionRecord;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GateDeps {
|
|
37
|
+
sessions: SessionService;
|
|
38
|
+
admission: AdmissionPolicy;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rejects the request unless there is a live session whose person is still
|
|
43
|
+
* admitted.
|
|
44
|
+
*
|
|
45
|
+
* Admission is re-read on every request, not decided once at sign-in. That is
|
|
46
|
+
* what makes a configuration change immediate: an operator who takes somebody
|
|
47
|
+
* out of `MICA_ADMIT_SUBJECTS` and restarts has removed them. What it cannot
|
|
48
|
+
* make immediate is a group removal at the provider — the claims here are the
|
|
49
|
+
* ones the provider sent, and Mica learns better at the next sign-in.
|
|
50
|
+
*/
|
|
51
|
+
export function requireAccess(deps: GateDeps): MiddlewareHandler<Env> {
|
|
52
|
+
return async (c, next) => {
|
|
53
|
+
let session = deps.sessions.resolve(getCookie(c, deps.sessions.names.session));
|
|
54
|
+
|
|
55
|
+
if (!session) {
|
|
56
|
+
throw new ApiError("unauthenticated", "Your session has expired. Please sign in again.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { identity } = session;
|
|
60
|
+
|
|
61
|
+
const verdict = deps.admission.admits(identity);
|
|
62
|
+
if (!verdict.admitted) {
|
|
63
|
+
c.get("logger").warn("Session no longer admitted", {
|
|
64
|
+
subject: identity.subject,
|
|
65
|
+
reason: verdict.reason,
|
|
66
|
+
});
|
|
67
|
+
throw new ApiError("forbidden", "You no longer have access to this site.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const refreshed = deps.sessions.refresh(session);
|
|
71
|
+
if (refreshed) session = refreshed.record;
|
|
72
|
+
|
|
73
|
+
c.set(SESSION_VARIABLE as never, session as never);
|
|
74
|
+
c.set("access", {
|
|
75
|
+
identity: { subject: identity.subject, email: identity.email, name: identity.name },
|
|
76
|
+
});
|
|
77
|
+
await next();
|
|
78
|
+
|
|
79
|
+
// After the handler, so the cookie lands on whatever response it built.
|
|
80
|
+
if (refreshed) {
|
|
81
|
+
setCookie(c, deps.sessions.names.session, refreshed.cookie, deps.sessions.sessionCookieOptions());
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Second CSRF layer: double-submit token. The session's token is issued in a
|
|
90
|
+
* cookie the SPA can read and must be echoed in a header.
|
|
91
|
+
*
|
|
92
|
+
* Runs after `requireAccess`, and complements the session cookie's
|
|
93
|
+
* `SameSite=Lax`, which already blocks the classic cross-site form post.
|
|
94
|
+
*/
|
|
95
|
+
export function csrfProtection(deps: GateDeps): MiddlewareHandler<Env> {
|
|
96
|
+
return async (c, next) => {
|
|
97
|
+
if (SAFE_METHODS.has(c.req.method)) {
|
|
98
|
+
await next();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const session = sessionOf(c);
|
|
103
|
+
const header = c.req.header(CSRF_HEADER);
|
|
104
|
+
const cookie = getCookie(c, deps.sessions.names.csrf);
|
|
105
|
+
|
|
106
|
+
if (!header || !cookie || !safeEqual(header, cookie) || !safeEqual(header, session.csrfToken)) {
|
|
107
|
+
throw new ApiError("forbidden", "Invalid or missing CSRF token. Reload the page and retry.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await next();
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.
|
|
3
|
+
*
|
|
4
|
+
* One factory: hand it the finished configuration and a logger, and it
|
|
5
|
+
* 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
|
+
|
|
14
|
+
import type { AuthSurface, Logger } from "@micacms/runtime";
|
|
15
|
+
import { createRateLimiter, type RateLimiter } from "@micacms/runtime";
|
|
16
|
+
|
|
17
|
+
import { createAdmissionPolicy, type AdmissionPolicy } from "./admission.js";
|
|
18
|
+
import type { AuthConfig } from "./config.js";
|
|
19
|
+
import { csrfProtection, requireAccess } from "./gate.js";
|
|
20
|
+
import { createOidcService, type OidcService } from "./oidc.js";
|
|
21
|
+
import { authRoutes } from "./routes.js";
|
|
22
|
+
import { createSessionService, type SessionService } from "./session.js";
|
|
23
|
+
|
|
24
|
+
export interface OidcAuthDeps {
|
|
25
|
+
config: AuthConfig;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OidcAuth {
|
|
30
|
+
/** What `createApp` mounts: routes at /api/auth, the gate before /api/admin. */
|
|
31
|
+
surface: AuthSurface;
|
|
32
|
+
sessions: SessionService;
|
|
33
|
+
admission: AdmissionPolicy;
|
|
34
|
+
oidc: OidcService;
|
|
35
|
+
loginLimiter: RateLimiter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createOidcAuth(deps: OidcAuthDeps): OidcAuth {
|
|
39
|
+
const { config, logger } = deps;
|
|
40
|
+
|
|
41
|
+
const sessions = createSessionService(config);
|
|
42
|
+
const admission = createAdmissionPolicy(config);
|
|
43
|
+
const oidc = createOidcService({ config, logger });
|
|
44
|
+
const loginLimiter = createRateLimiter({
|
|
45
|
+
limit: config.MICA_SIGNIN_MAX_ATTEMPTS,
|
|
46
|
+
windowMs: config.MICA_SIGNIN_WINDOW_MS,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const gate = { sessions, admission };
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
surface: {
|
|
53
|
+
routes: authRoutes({ config, sessions, oidc, admission, loginLimiter }),
|
|
54
|
+
gate: [requireAccess(gate), csrfProtection(gate)],
|
|
55
|
+
},
|
|
56
|
+
sessions,
|
|
57
|
+
admission,
|
|
58
|
+
oidc,
|
|
59
|
+
loginLimiter,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { createAdmissionPolicy, type AdmissionPolicy, type AdmissionResult } from "./admission.js";
|
|
64
|
+
export {
|
|
65
|
+
authEnv,
|
|
66
|
+
deriveAuth,
|
|
67
|
+
requireAuthConfig,
|
|
68
|
+
type AuthConfig,
|
|
69
|
+
type AuthEnv,
|
|
70
|
+
} from "./config.js";
|
|
71
|
+
export { csrfProtection, requireAccess, sessionOf } from "./gate.js";
|
|
72
|
+
export { createOidcService, type OidcService } from "./oidc.js";
|
|
73
|
+
export {
|
|
74
|
+
cookieNames,
|
|
75
|
+
createSessionService,
|
|
76
|
+
CSRF_COOKIE,
|
|
77
|
+
CSRF_HEADER,
|
|
78
|
+
SESSION_COOKIE,
|
|
79
|
+
type SessionIdentity,
|
|
80
|
+
type SessionRecord,
|
|
81
|
+
type SessionService,
|
|
82
|
+
} from "./session.js";
|