@panaversity/ksor 0.0.33 → 0.0.35
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/CHANGELOG.md +93 -0
- package/docs/authorization.md +5 -2
- package/docs/deploying.md +142 -0
- package/docs/index.md +3 -3
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +2 -2
- package/templates/scaffold/README.md +7 -0
- package/templates/scaffold/env.example +17 -0
- package/templates/scaffold/system/site/app/auth/callback/page.tsx +53 -0
- package/templates/scaffold/system/site/components/sign-in.tsx +94 -0
- package/templates/scaffold/system/site/lib/auth/config.ts +53 -0
- package/templates/scaffold/system/site/lib/auth/discovery.ts +53 -0
- package/templates/scaffold/system/site/lib/auth/flow.ts +158 -0
- package/templates/scaffold/system/site/lib/auth/pkce.ts +42 -0
- package/templates/scaffold/system/site/lib/auth/session.ts +81 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +6 -0
- package/templates/scaffold/system/site/next.config.mjs +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.35
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0fc6bce: Name the reader on the website
|
|
8
|
+
|
|
9
|
+
The scaffolded site can now sign a reader in and show who they are in the
|
|
10
|
+
navbar. It is off until three variables are set — the control does not render at
|
|
11
|
+
all without them, which stays the default.
|
|
12
|
+
|
|
13
|
+
The flow is OAuth 2.0 Authorization Code with PKCE against a public client, with
|
|
14
|
+
no secret anywhere in a build that ships to browsers. Endpoints are discovered
|
|
15
|
+
(RFC 8414, then OIDC), so no vendor is named in the code or in configuration;
|
|
16
|
+
verified end to end against Auth0 and against a Better Auth deployment. The
|
|
17
|
+
session lives in `sessionStorage` for the tab, and no refresh token is requested
|
|
18
|
+
or stored — a token that unlocks nothing on this site should not outlive the
|
|
19
|
+
visit.
|
|
20
|
+
|
|
21
|
+
What it does NOT do is restrict reading, and the documentation leads with that.
|
|
22
|
+
The site is a static export: every published document is a file the host serves
|
|
23
|
+
to whoever asks, so keeping people out is still the origin gate or a per-audience
|
|
24
|
+
build, both unchanged. This names an already-authenticated reader; it is not a
|
|
25
|
+
step toward access control, and treating it as one would be the mistake the
|
|
26
|
+
"Keeping people out of the site" section exists to prevent.
|
|
27
|
+
|
|
28
|
+
Also fixes a real gap it exposed: the site build never read the repository-root
|
|
29
|
+
`.env`, so following the scaffold's own instructions would have set variables
|
|
30
|
+
that silently never reached the bundle.
|
|
31
|
+
|
|
32
|
+
- 4b431b2: Trim the shell-retirement revision in AGENTS.md from 222 words to 129, keeping
|
|
33
|
+
what an agent must act on and moving the reasoning to the commit that carried
|
|
34
|
+
it.
|
|
35
|
+
|
|
36
|
+
Working rule 6 requires a reversed decision to keep its entry and gain a
|
|
37
|
+
revision note, so removing it is not available — and it is not irrelevant
|
|
38
|
+
either: without it an agent looks for a deleted directory with no explanation,
|
|
39
|
+
or restores two-shell assertions thinking they were lost by accident, or reads
|
|
40
|
+
decision 9, sees no obstacle, and treats dropping `output: "export"` as
|
|
41
|
+
unblocked. That last one is the reason it stays.
|
|
42
|
+
|
|
43
|
+
But coding principle 1 applies to this file too — context is liability, and
|
|
44
|
+
AGENTS.md loads every session. The narrative half was 90 words explaining why
|
|
45
|
+
the proof had been valuable, which the commit already records.
|
|
46
|
+
|
|
47
|
+
Also documents the thing that was missing entirely: **how to keep people out of
|
|
48
|
+
the site.** The door's auth had four recipes; the site had nothing, and the
|
|
49
|
+
most common requirement — "everyone signs in before reading anything" — is also
|
|
50
|
+
the easiest, needs no ksor change, and was written down nowhere.
|
|
51
|
+
|
|
52
|
+
Three shapes, separated because they had been muddled: a host-level gate in
|
|
53
|
+
front of the origin (protects every byte, holds against `curl`, and makes a
|
|
54
|
+
site sign-in button redundant rather than complementary); per-audience builds
|
|
55
|
+
for a restricted subset (enforcement by absence, already built); and the
|
|
56
|
+
per-request case, which a static export cannot express and which issue #130
|
|
57
|
+
records rather than implements. Plus what does not work — hiding rendered
|
|
58
|
+
content behind a browser check, which presents rather than protects.
|
|
59
|
+
|
|
60
|
+
The per-request case gets three answers rather than a deferral: **read through
|
|
61
|
+
the door** (already applies audience scope per request and logs an actor per
|
|
62
|
+
read — per-person governance with an audit trail a static site cannot have),
|
|
63
|
+
**split the record** (content needing per-person confidentiality inside one tier
|
|
64
|
+
usually belongs in its own record), or **fork the site**, which an adopter owns
|
|
65
|
+
outright under decision 4.
|
|
66
|
+
|
|
67
|
+
The fork is offered with what it costs stated: ksor's guarantee is enforcement
|
|
68
|
+
by ABSENCE, asserted against a positive control; a request-time filter is a
|
|
69
|
+
different guarantee and becomes the adopter's to test. A filter that is bypassed
|
|
70
|
+
serves the document; an absent file cannot be.
|
|
71
|
+
|
|
72
|
+
## 0.0.34
|
|
73
|
+
|
|
74
|
+
### Patch Changes
|
|
75
|
+
|
|
76
|
+
- e18ea08: The second site shell is retired (decision 9 revision, owner). Nothing an
|
|
77
|
+
adopter runs changes: `ksor init` has always emitted Fumadocs and never offered
|
|
78
|
+
a selector.
|
|
79
|
+
|
|
80
|
+
The proof did its job. It was built to answer whether the shell is really a slot
|
|
81
|
+
or whether the surface contract was just a description of what Fumadocs happens
|
|
82
|
+
to do, and it kept that contract honest through the visibility model,
|
|
83
|
+
attachments and the staging lock. What it costs now exceeds that: every surface
|
|
84
|
+
the record grows — quizzes, decks, slides, code tabs — has to be built twice or
|
|
85
|
+
the conformance suite goes red, and the second build is one nobody ships.
|
|
86
|
+
Maintaining a shell no adopter runs, to prove a property no adopter exercises,
|
|
87
|
+
is the "code is liability" test failing.
|
|
88
|
+
|
|
89
|
+
The five-clause surface contract survives unchanged and is still asserted,
|
|
90
|
+
against one implementation. The other clauses decision 9 always leaned on —
|
|
91
|
+
adopter ownership of `system/site`, registry-distributed shells later — are what
|
|
92
|
+
carry replaceability now. The swap recipe lives in git history; an adopter
|
|
93
|
+
actually swapping a shell restores the property as something live rather than
|
|
94
|
+
hypothetical.
|
|
95
|
+
|
|
3
96
|
## 0.0.33
|
|
4
97
|
|
|
5
98
|
### Patch Changes
|
package/docs/authorization.md
CHANGED
|
@@ -32,8 +32,11 @@ Read this before spending an afternoon on a provider's console.
|
|
|
32
32
|
**It protects the MCP door, not the website.** Everything on this page is a
|
|
33
33
|
bearer token on `POST /mcp`. Your static site is a separate surface, served by
|
|
34
34
|
whatever hosts it, and configuring auth here leaves it exactly as public as it
|
|
35
|
-
was.
|
|
36
|
-
|
|
35
|
+
was. Keeping people out of the SITE is a different mechanism and is not on this
|
|
36
|
+
page — see "Keeping people out of the site" in
|
|
37
|
+
[deploying.md](./deploying.md), which covers the three shapes: a host-level gate
|
|
38
|
+
in front of everything, per-audience builds for a restricted subset, and why the
|
|
39
|
+
per-request case needs a decision first.
|
|
37
40
|
|
|
38
41
|
**It is one gate, not per-user rules.** The door checks that a token was signed
|
|
39
42
|
by the issuer you named and audienced at this record. It reads no scopes, no
|
package/docs/deploying.md
CHANGED
|
@@ -246,6 +246,148 @@ a row) and reaches the site at its next build (it reads a file), so a site built
|
|
|
246
246
|
without the DSN would keep publishing what the door already refuses. Set
|
|
247
247
|
`KSOR_DB_URL` on the site build as well as on the door.
|
|
248
248
|
|
|
249
|
+
## Keeping people out of the site
|
|
250
|
+
|
|
251
|
+
The door has auth ([authorization.md](./authorization.md)). The **site** is
|
|
252
|
+
static files, so it has none — and the way to protect it is not to add code, it
|
|
253
|
+
is to put something in front of it.
|
|
254
|
+
|
|
255
|
+
Three requirements, three different answers. Pick the row you actually have.
|
|
256
|
+
|
|
257
|
+
### "Everyone must sign in before reading anything"
|
|
258
|
+
|
|
259
|
+
**Put a gate in front of the origin.** Nothing in ksor changes, and it protects
|
|
260
|
+
every byte — HTML, `llms.txt`, images, the search index — because the request
|
|
261
|
+
never reaches the files.
|
|
262
|
+
|
|
263
|
+
| host | what to turn on |
|
|
264
|
+
| ------------- | ---------------------------------------------------------------------------------------------------- |
|
|
265
|
+
| Vercel | Deployment Protection (password, or SSO on paid plans) |
|
|
266
|
+
| Cloudflare | Cloudflare Access in front of the deployment |
|
|
267
|
+
| anything else | an authenticating reverse proxy — nginx with `auth_request`, oauth2-proxy, Caddy with `forward_auth` |
|
|
268
|
+
|
|
269
|
+
This is the strongest gate available to a static site, and the only one that
|
|
270
|
+
holds against `curl`. It is coarse — whole deployment, all or nothing — which is
|
|
271
|
+
exactly right when the answer is "this record is internal".
|
|
272
|
+
|
|
273
|
+
**A sign-in button on the site is not an alternative to this.** The gate has
|
|
274
|
+
already authenticated the reader before a page renders; a second login inside it
|
|
275
|
+
would ask the same person to sign in twice, and on its own would protect
|
|
276
|
+
nothing.
|
|
277
|
+
|
|
278
|
+
### "Some documents are restricted, most are not"
|
|
279
|
+
|
|
280
|
+
**Build per audience.** `KSOR_AUDIENCE=<tier> pnpm build` stages only what that
|
|
281
|
+
tier may see, so restricted documents are **never written into the artifact** —
|
|
282
|
+
enforcement by absence, which is the only kind a static host can honour. Publish
|
|
283
|
+
the public artifact openly and the wider one behind the gate above.
|
|
284
|
+
|
|
285
|
+
Plain `pnpm build` is always the public tier, so the safe thing is the default.
|
|
286
|
+
|
|
287
|
+
### "Different readers see different documents, decided per request"
|
|
288
|
+
|
|
289
|
+
Two supported answers, and a third that is yours.
|
|
290
|
+
|
|
291
|
+
**Read through the door instead.** This is the one ksor is built for. The MCP
|
|
292
|
+
surface already applies the audience scope **per request** and writes a
|
|
293
|
+
`retrieval_log` row carrying the actor for every read — per-person governance
|
|
294
|
+
with an audit trail, which a static site cannot have at any price. If the
|
|
295
|
+
requirement is "who read what, and were they allowed to", that is the door, not
|
|
296
|
+
the website.
|
|
297
|
+
|
|
298
|
+
**Or split the record.** Content needing per-person confidentiality inside one
|
|
299
|
+
tier is usually content that belongs in its own record, with its own gate. That
|
|
300
|
+
is what the audience model and the second-record design anticipate.
|
|
301
|
+
|
|
302
|
+
**Or fork the site — you already own it.** `system/site` is yours outright
|
|
303
|
+
(decision 4). Nothing stops you removing `output: "export"` and filtering per
|
|
304
|
+
request in your own repository. ksor's contract is unaffected; this is a
|
|
305
|
+
directory you own, changed the way you want it.
|
|
306
|
+
|
|
307
|
+
What you take on if you do:
|
|
308
|
+
|
|
309
|
+
> ksor's guarantee is **enforcement by absence** — a restricted document is
|
|
310
|
+
> never written into the artifact, and a conformance suite asserts that against
|
|
311
|
+
> a positive control that proves the check is not blind. A request-time filter
|
|
312
|
+
> is a **different** guarantee, and it becomes yours to test, because those
|
|
313
|
+
> suites will no longer be testing it for you. A filter that is bypassed serves
|
|
314
|
+
> the document; an absent file cannot be.
|
|
315
|
+
|
|
316
|
+
That is the whole trade. It is a reasonable thing to do with your eyes open, and
|
|
317
|
+
a bad thing to drift into because a login button suggested it.
|
|
318
|
+
|
|
319
|
+
### What does NOT work
|
|
320
|
+
|
|
321
|
+
**Hiding rendered content behind a signed-in check in the browser.** If the page
|
|
322
|
+
was built with the content in it, the content is in the response before any
|
|
323
|
+
JavaScript runs — `curl` and every crawler see it. A component that blurs or
|
|
324
|
+
collapses it is presenting, not protecting. If you build one, say so in its own
|
|
325
|
+
comment, or the next reader will take it for a gate.
|
|
326
|
+
|
|
327
|
+
## Naming the reader — the sign-in control
|
|
328
|
+
|
|
329
|
+
The site ships an optional sign-in control. Read the section above before you
|
|
330
|
+
turn it on, because the one thing it does not do is the thing its name suggests.
|
|
331
|
+
|
|
332
|
+
**What it does:** signs the reader in against your authorization server and puts
|
|
333
|
+
their name in the navbar. That is the whole feature.
|
|
334
|
+
|
|
335
|
+
**What it does not do:** restrict anything. The site is a static export — every
|
|
336
|
+
published document is a file the host hands to whoever asks, and no amount of
|
|
337
|
+
browser JavaScript changes that. If the requirement is "keep people out", the
|
|
338
|
+
answer is the origin gate above, and the sign-in control is not a step toward it.
|
|
339
|
+
|
|
340
|
+
So the honest use is a record already behind a gate, where the reader is
|
|
341
|
+
authenticated but anonymous to the page, and you want the navbar to say who they
|
|
342
|
+
are and offer a way out. That is worth having. It is not access control.
|
|
343
|
+
|
|
344
|
+
### Turning it on
|
|
345
|
+
|
|
346
|
+
Register a **public** client — PKCE, no secret — at the same authorization
|
|
347
|
+
server the door names in `KSOR_SSO_URL`, then set three variables in the
|
|
348
|
+
repository-root `.env`:
|
|
349
|
+
|
|
350
|
+
```sh
|
|
351
|
+
NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
|
|
352
|
+
NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=your-client-id
|
|
353
|
+
NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
All three or none: with any of them missing the control does not render, which
|
|
357
|
+
is the default and is not an error.
|
|
358
|
+
|
|
359
|
+
They are `NEXT_PUBLIC_`, so they are **inlined at build time**. Set them before
|
|
360
|
+
`pnpm build`; setting them on a running site changes nothing. This also means
|
|
361
|
+
they are public — which is correct, because a public client has nothing secret
|
|
362
|
+
to leak, and it is the reason none of these is a secret.
|
|
363
|
+
|
|
364
|
+
Two things to get exactly right at the provider:
|
|
365
|
+
|
|
366
|
+
- **The redirect URI must match byte for byte**, including the scheme and any
|
|
367
|
+
trailing slash. This is the failure everyone hits first, and providers report
|
|
368
|
+
it as a generic callback mismatch.
|
|
369
|
+
- **Add the site's origin to the allowed web origins** (Auth0 calls it that;
|
|
370
|
+
others call it CORS). The token exchange is a browser `fetch`, so a missing
|
|
371
|
+
origin fails as CORS, not as auth.
|
|
372
|
+
|
|
373
|
+
For local work, both values are `http://localhost:3000` — and the callback is
|
|
374
|
+
`http://localhost:3000/auth/callback`.
|
|
375
|
+
|
|
376
|
+
Endpoints are **discovered**, not configured: the control reads
|
|
377
|
+
`/.well-known/oauth-authorization-server`, then OIDC discovery. Any provider
|
|
378
|
+
publishing either one works, which is why there is no vendor setting here.
|
|
379
|
+
Verified against Auth0 and against a Better Auth deployment.
|
|
380
|
+
|
|
381
|
+
### What it stores, and for how long
|
|
382
|
+
|
|
383
|
+
The session lives in `sessionStorage` — this tab, until it closes. No refresh
|
|
384
|
+
token is requested and none is stored.
|
|
385
|
+
|
|
386
|
+
That is deliberate, and it is a smaller footprint than the obvious alternative.
|
|
387
|
+
A token that unlocks nothing on this site should not outlive the visit; the
|
|
388
|
+
blast radius should match the benefit. If you need a longer session, you need
|
|
389
|
+
the gate, not a longer-lived token in a browser.
|
|
390
|
+
|
|
249
391
|
## Authorization, or the deliberate absence of it
|
|
250
392
|
|
|
251
393
|
`ksor serve` **refuses to boot unauthenticated on a public bind.** There is no
|
package/docs/index.md
CHANGED
|
@@ -68,9 +68,9 @@ instance declares an `audiences:` model, documents may carry a
|
|
|
68
68
|
`visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
|
|
69
69
|
build`) stage only what that tier may see — publication, not authorship:
|
|
70
70
|
anyone who can clone reads everything. The site shell
|
|
71
|
-
at `system/site/` is replaceable behind a five-clause surface contract
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
at `system/site/` is replaceable behind a five-clause surface contract — the
|
|
72
|
+
shell is a slot, and the contract (render the record, `llms.txt`, per-page md
|
|
73
|
+
artifacts, browser smoke, no authored content) is what a replacement must meet.
|
|
74
74
|
|
|
75
75
|
## Where truth lives
|
|
76
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.35",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -708,8 +708,8 @@ You own `system/site/` outright — these are the seams, cheapest first:
|
|
|
708
708
|
documentation shell around it stay one palette.
|
|
709
709
|
- **Anything deeper** — edit the site like the Next.js app it is; the only
|
|
710
710
|
rule that survives customization is critical rule 1. The whole shell is
|
|
711
|
-
replaceable behind a five-clause contract
|
|
712
|
-
|
|
711
|
+
replaceable behind a five-clause contract: render the record, `llms.txt`,
|
|
712
|
+
per-page md artifacts, browser smoke, and no authored content.
|
|
713
713
|
|
|
714
714
|
## What this project owns
|
|
715
715
|
|
|
@@ -233,6 +233,13 @@ put it behind access control you already trust (VPN, SSO proxy,
|
|
|
233
233
|
authenticated host). The tiers govern what a build contains; where each
|
|
234
234
|
build may be served is yours to enforce.
|
|
235
235
|
|
|
236
|
+
The site can also show a **sign-in control** that names the reader in the
|
|
237
|
+
navbar. It is off until you set three variables (see `.env.example`), and it
|
|
238
|
+
names people rather than keeping them out — a static export cannot gate itself,
|
|
239
|
+
so it is worth having on a record already behind one of the answers above, and
|
|
240
|
+
is not a substitute for them. Setup and the honest limits:
|
|
241
|
+
`node_modules/@panaversity/ksor/docs/deploying.md`.
|
|
242
|
+
|
|
236
243
|
## Ownership
|
|
237
244
|
|
|
238
245
|
Everything here is yours. The scaffold was generated by
|
|
@@ -41,6 +41,23 @@ GEMINI_API_KEY=
|
|
|
41
41
|
# KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
|
|
42
42
|
KSOR_AUTH=disabled-local
|
|
43
43
|
|
|
44
|
+
# ── Sign-in on the WEBSITE (optional) ───────────────────────────────────────
|
|
45
|
+
# Names the reader in the navbar. It does NOT restrict reading: the site is a
|
|
46
|
+
# static export, so every published document is a file the host serves to
|
|
47
|
+
# whoever asks. To actually keep people out, see docs/deploying.md →
|
|
48
|
+
# "Keeping people out of the site".
|
|
49
|
+
#
|
|
50
|
+
# All three are required together, and are inlined at BUILD time — set them
|
|
51
|
+
# before `pnpm build`, not on the running site. Leave them unset and the
|
|
52
|
+
# sign-in control does not render at all.
|
|
53
|
+
#
|
|
54
|
+
# The issuer is the same authorization server the door names in KSOR_SSO_URL.
|
|
55
|
+
# Register a PUBLIC client (PKCE, no secret) whose redirect list contains the
|
|
56
|
+
# callback URL below, written out literally.
|
|
57
|
+
# NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
|
|
58
|
+
# NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=
|
|
59
|
+
# NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
|
|
60
|
+
|
|
44
61
|
# ── Production knobs ────────────────────────────────────────────────────────
|
|
45
62
|
# Unset is fine for a local run; each one matters once this serves for real.
|
|
46
63
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the issuer sends the reader back.
|
|
5
|
+
*
|
|
6
|
+
* A PAGE, not a route handler. Under `output: "export"` a route handler cannot
|
|
7
|
+
* share a segment with a page, and more to the point there is no server to run
|
|
8
|
+
* one — the whole exchange happens in the browser, which is what lets a static
|
|
9
|
+
* site be an OAuth client at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
|
|
14
|
+
import { completeSignIn } from "@/lib/auth/flow";
|
|
15
|
+
|
|
16
|
+
export default function CallbackPage(): React.ReactElement {
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
void (async () => {
|
|
22
|
+
const result = await completeSignIn(new URLSearchParams(window.location.search));
|
|
23
|
+
if (cancelled) return;
|
|
24
|
+
if (result.ok) {
|
|
25
|
+
// replace(), not assign(): the callback URL carries a spent
|
|
26
|
+
// authorization code, and leaving it in history means Back re-runs a
|
|
27
|
+
// flow that can only fail the second time.
|
|
28
|
+
window.location.replace(result.returnTo || "/");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setError(result.reason);
|
|
32
|
+
})();
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<main className="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-3 px-6 text-center">
|
|
40
|
+
{error === null ? (
|
|
41
|
+
<p className="text-muted-foreground text-sm">Signing you in…</p>
|
|
42
|
+
) : (
|
|
43
|
+
<>
|
|
44
|
+
<h1 className="text-lg font-semibold">Sign-in did not complete</h1>
|
|
45
|
+
<p className="text-muted-foreground text-sm">{error}</p>
|
|
46
|
+
<a className="text-sm underline underline-offset-4" href="/">
|
|
47
|
+
Back to the record
|
|
48
|
+
</a>
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
</main>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The navbar's sign-in control.
|
|
5
|
+
*
|
|
6
|
+
* Renders NOTHING when this record does not configure an issuer, so a scaffold
|
|
7
|
+
* that never sets one looks exactly as it does today — no placeholder, no
|
|
8
|
+
* disabled button, no hint that a feature is missing.
|
|
9
|
+
*
|
|
10
|
+
* What it does NOT do, said here because a sign-in control implies it: gate
|
|
11
|
+
* anything. The record's pages are static files the host serves to whoever
|
|
12
|
+
* asks. This names a reader; it does not decide what they may read.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { LogIn, LogOut, User } from "lucide-react";
|
|
16
|
+
import { useEffect, useState } from "react";
|
|
17
|
+
|
|
18
|
+
import { Button } from "@/components/ui/button";
|
|
19
|
+
import {
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuLabel,
|
|
24
|
+
DropdownMenuSeparator,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "@/components/ui/dropdown-menu";
|
|
27
|
+
import { authConfig } from "@/lib/auth/config";
|
|
28
|
+
import { beginSignIn } from "@/lib/auth/flow";
|
|
29
|
+
import { clearSession, readSession, type Session } from "@/lib/auth/session";
|
|
30
|
+
|
|
31
|
+
export function SignIn(): React.ReactElement | null {
|
|
32
|
+
// Read AFTER mount, never during render: the session lives in sessionStorage,
|
|
33
|
+
// which the prerender has no access to. Reading it during render would make
|
|
34
|
+
// the server-rendered HTML and the first client render disagree, and React
|
|
35
|
+
// would blow the tree away with a hydration error.
|
|
36
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
37
|
+
const [ready, setReady] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setSession(readSession());
|
|
41
|
+
setReady(true);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
if (authConfig === null) return null;
|
|
45
|
+
|
|
46
|
+
// Hold the space until mounted, so the navbar does not jump when the session
|
|
47
|
+
// resolves one frame later.
|
|
48
|
+
if (!ready) return <div className="h-8 w-20" aria-hidden />;
|
|
49
|
+
|
|
50
|
+
if (session === null) {
|
|
51
|
+
return (
|
|
52
|
+
<Button
|
|
53
|
+
variant="outline"
|
|
54
|
+
size="sm"
|
|
55
|
+
onClick={() => {
|
|
56
|
+
void beginSignIn(window.location.pathname + window.location.search);
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<LogIn className="size-4" aria-hidden />
|
|
60
|
+
Sign in
|
|
61
|
+
</Button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const label = session.name ?? session.email ?? "Signed in";
|
|
66
|
+
return (
|
|
67
|
+
<DropdownMenu>
|
|
68
|
+
<DropdownMenuTrigger asChild>
|
|
69
|
+
<Button variant="ghost" size="sm" aria-label={`Signed in as ${label}`}>
|
|
70
|
+
<User className="size-4" aria-hidden />
|
|
71
|
+
<span className="max-w-32 truncate">{label}</span>
|
|
72
|
+
</Button>
|
|
73
|
+
</DropdownMenuTrigger>
|
|
74
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
75
|
+
<DropdownMenuLabel className="font-normal">
|
|
76
|
+
<span className="block truncate text-sm font-medium">{label}</span>
|
|
77
|
+
{session.email !== null && session.email !== label ? (
|
|
78
|
+
<span className="text-muted-foreground block truncate text-xs">{session.email}</span>
|
|
79
|
+
) : null}
|
|
80
|
+
</DropdownMenuLabel>
|
|
81
|
+
<DropdownMenuSeparator />
|
|
82
|
+
<DropdownMenuItem
|
|
83
|
+
onClick={() => {
|
|
84
|
+
clearSession();
|
|
85
|
+
setSession(null);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<LogOut className="size-4" aria-hidden />
|
|
89
|
+
Sign out
|
|
90
|
+
</DropdownMenuItem>
|
|
91
|
+
</DropdownMenuContent>
|
|
92
|
+
</DropdownMenu>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this record's site offers sign-in, and against whom.
|
|
3
|
+
*
|
|
4
|
+
* Sign-in is ABSENT unless an issuer is configured. There is no half-state and
|
|
5
|
+
* no localhost fallback: a build with no issuer renders no control, and every
|
|
6
|
+
* function here returns null rather than guessing. That is the same posture the
|
|
7
|
+
* door takes (`ksor serve` refuses to boot rather than serve an undeclared
|
|
8
|
+
* auth state) expressed in the only way a static build can — by not existing.
|
|
9
|
+
*
|
|
10
|
+
* What this buys, stated once so nobody has to infer it: a VERIFIED reader
|
|
11
|
+
* identity, rather than a guessed one. It protects nothing the site publishes,
|
|
12
|
+
* and nothing in the browser calls the door yet — the flow requests `openid
|
|
13
|
+
* profile email` and no audience, so the access token it receives is the
|
|
14
|
+
* issuer's own and is not something the door would accept. Making the browser
|
|
15
|
+
* a door client means requesting this record's resource (RFC 8707) and is a
|
|
16
|
+
* new read surface, not a config change. Under `output: "export"` every document is a file the host serves
|
|
17
|
+
* to whoever asks; see `docs/deploying.md` → "Keeping people out of the site"
|
|
18
|
+
* for the three mechanisms that actually restrict reading.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Values are inlined at BUILD time, so they must be NEXT_PUBLIC_ to exist. */
|
|
22
|
+
export interface AuthConfig {
|
|
23
|
+
/** The authorization server's base URL — the same issuer the door names. */
|
|
24
|
+
readonly issuer: string;
|
|
25
|
+
/** This site's public OAuth client. No secret: PKCE proves the caller. */
|
|
26
|
+
readonly clientId: string;
|
|
27
|
+
/**
|
|
28
|
+
* The callback URL, written out LITERALLY.
|
|
29
|
+
*
|
|
30
|
+
* Never derived from `window.location`. A derivation has to guess whether the
|
|
31
|
+
* first path segment is a base path, and guesses wrong under `KSOR_BASE_PATH`
|
|
32
|
+
* — producing a `redirect_uri` the issuer rejects, with an error that names
|
|
33
|
+
* the URI and not the guess that built it.
|
|
34
|
+
*/
|
|
35
|
+
readonly redirectUri: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const issuer = process.env["NEXT_PUBLIC_KSOR_SSO_URL"]?.trim() ?? "";
|
|
39
|
+
const clientId = process.env["NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID"]?.trim() ?? "";
|
|
40
|
+
const redirectUri = process.env["NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI"]?.trim() ?? "";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The configuration, or null when this record does not offer sign-in.
|
|
44
|
+
*
|
|
45
|
+
* All three are required together. Two out of three is a misconfiguration that
|
|
46
|
+
* would otherwise surface as a failed redirect on the reader's screen, so it
|
|
47
|
+
* resolves to "no sign-in" and the control never renders.
|
|
48
|
+
*/
|
|
49
|
+
export const authConfig: AuthConfig | null =
|
|
50
|
+
issuer !== "" && clientId !== "" && redirectUri !== "" ? { issuer, clientId, redirectUri } : null;
|
|
51
|
+
|
|
52
|
+
/** Where the record's own scopes end: identity only, and no refresh token. */
|
|
53
|
+
export const OAUTH_SCOPE = "openid profile email";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the issuer's endpoints actually are — asked, never assumed.
|
|
3
|
+
*
|
|
4
|
+
* Hardcoding `/authorize` and `/oauth/token` would work against one vendor and
|
|
5
|
+
* fail against the next, which is the opposite of what this project claims.
|
|
6
|
+
* Every standards-compliant authorization server publishes a metadata document
|
|
7
|
+
* naming its own endpoints, and the door already reads exactly these two
|
|
8
|
+
* (`gateway-kit/src/jwks-discovery.ts`): RFC 8414 first, then OpenID Discovery.
|
|
9
|
+
*
|
|
10
|
+
* So the site and the door discover the same issuer the same way, and pointing
|
|
11
|
+
* both at a different provider stays an environment change.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface Endpoints {
|
|
15
|
+
readonly authorization_endpoint: string;
|
|
16
|
+
readonly token_endpoint: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PATHS = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
|
|
20
|
+
|
|
21
|
+
function isEndpoints(value: unknown): value is Endpoints {
|
|
22
|
+
if (typeof value !== "object" || value === null) return false;
|
|
23
|
+
const v = value as Record<string, unknown>;
|
|
24
|
+
return typeof v["authorization_endpoint"] === "string" && typeof v["token_endpoint"] === "string";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let cached: Endpoints | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The issuer's endpoints, or null if it publishes no usable metadata.
|
|
31
|
+
*
|
|
32
|
+
* Memoized for the page's lifetime: the flow reads it twice (once to leave,
|
|
33
|
+
* once to come back) and those are separate page loads, so this saves a request
|
|
34
|
+
* only within one of them. Correctness does not depend on it.
|
|
35
|
+
*/
|
|
36
|
+
export async function discover(issuer: string): Promise<Endpoints | null> {
|
|
37
|
+
if (cached !== null) return cached;
|
|
38
|
+
const base = issuer.replace(/\/+$/, "");
|
|
39
|
+
for (const path of PATHS) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(`${base}${path}`);
|
|
42
|
+
if (!response.ok) continue;
|
|
43
|
+
const document: unknown = await response.json();
|
|
44
|
+
if (isEndpoints(document)) {
|
|
45
|
+
cached = document;
|
|
46
|
+
return document;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* try the next document; a provider publishing neither is the null case */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two halves of an authorization-code flow, as plain functions.
|
|
3
|
+
*
|
|
4
|
+
* Everything is `fetch` and `window.location`. Nothing here needs a server on
|
|
5
|
+
* this origin, which is why a static export can do it at all.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { authConfig, OAUTH_SCOPE, type AuthConfig } from "./config";
|
|
9
|
+
import { discover } from "./discovery";
|
|
10
|
+
import { createPkcePair, createState } from "./pkce";
|
|
11
|
+
import { writeSession, type Session } from "./session";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The verifier and state, held across the redirect.
|
|
15
|
+
*
|
|
16
|
+
* `sessionStorage`, not a variable: the browser leaves this origin entirely and
|
|
17
|
+
* comes back to a fresh page load, so anything in memory is gone. It is also
|
|
18
|
+
* not `localStorage` — this is single-flow, single-tab state that should not
|
|
19
|
+
* outlive the tab that started it.
|
|
20
|
+
*/
|
|
21
|
+
const PENDING = "ksor.oauth.pending";
|
|
22
|
+
|
|
23
|
+
interface Pending {
|
|
24
|
+
readonly verifier: string;
|
|
25
|
+
readonly state: string;
|
|
26
|
+
/** Where the reader was, so sign-in returns them there. */
|
|
27
|
+
readonly returnTo: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Send the reader to the issuer. Returns only if it could not start. */
|
|
31
|
+
export async function beginSignIn(returnTo: string): Promise<void> {
|
|
32
|
+
const config: AuthConfig | null = authConfig;
|
|
33
|
+
if (config === null) return;
|
|
34
|
+
|
|
35
|
+
const { verifier, challenge } = await createPkcePair();
|
|
36
|
+
const state = createState();
|
|
37
|
+
const pending: Pending = { verifier, state, returnTo };
|
|
38
|
+
sessionStorage.setItem(PENDING, JSON.stringify(pending));
|
|
39
|
+
|
|
40
|
+
const endpoints = await discover(config.issuer);
|
|
41
|
+
if (endpoints === null) return;
|
|
42
|
+
|
|
43
|
+
const url = new URL(endpoints.authorization_endpoint);
|
|
44
|
+
url.searchParams.set("response_type", "code");
|
|
45
|
+
url.searchParams.set("client_id", config.clientId);
|
|
46
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
47
|
+
url.searchParams.set("scope", OAUTH_SCOPE);
|
|
48
|
+
url.searchParams.set("state", state);
|
|
49
|
+
url.searchParams.set("code_challenge", challenge);
|
|
50
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
51
|
+
window.location.assign(url.toString());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CallbackResult =
|
|
55
|
+
| { readonly ok: true; readonly returnTo: string }
|
|
56
|
+
| { readonly ok: false; readonly reason: string };
|
|
57
|
+
|
|
58
|
+
interface TokenResponse {
|
|
59
|
+
readonly access_token?: unknown;
|
|
60
|
+
readonly id_token?: unknown;
|
|
61
|
+
readonly expires_in?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Claims this site reads. Everything else in the token is ignored. */
|
|
65
|
+
interface IdClaims {
|
|
66
|
+
readonly sub?: unknown;
|
|
67
|
+
readonly name?: unknown;
|
|
68
|
+
readonly email?: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Decode a JWT payload WITHOUT verifying it. Read the caveat before using. */
|
|
72
|
+
function decodeClaims(idToken: string): IdClaims | null {
|
|
73
|
+
try {
|
|
74
|
+
const payload = idToken.split(".")[1];
|
|
75
|
+
if (payload === undefined) return null;
|
|
76
|
+
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
77
|
+
const parsed: unknown = JSON.parse(json);
|
|
78
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as IdClaims) : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Redeem the code the issuer sent back, and store the session.
|
|
88
|
+
*
|
|
89
|
+
* The id_token's claims are decoded, NOT verified, and that is deliberate: they
|
|
90
|
+
* are used only to render a name in this browser's own navbar. A forged token
|
|
91
|
+
* would let a reader lie to themselves about their own display name and nothing
|
|
92
|
+
* else — no content is gated on it, and the ACCESS token is verified by the
|
|
93
|
+
* server that actually accepts it (the door checks signature, issuer and
|
|
94
|
+
* audience per request). Verifying here would mean shipping a JWKS client to
|
|
95
|
+
* defend against the reader's own devtools.
|
|
96
|
+
*/
|
|
97
|
+
export async function completeSignIn(params: URLSearchParams): Promise<CallbackResult> {
|
|
98
|
+
const config: AuthConfig | null = authConfig;
|
|
99
|
+
if (config === null) return { ok: false, reason: "This record does not offer sign-in." };
|
|
100
|
+
|
|
101
|
+
const issuerError = params.get("error");
|
|
102
|
+
if (issuerError !== null) {
|
|
103
|
+
return { ok: false, reason: params.get("error_description") ?? issuerError };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const raw = sessionStorage.getItem(PENDING);
|
|
107
|
+
sessionStorage.removeItem(PENDING);
|
|
108
|
+
if (raw === null) {
|
|
109
|
+
return { ok: false, reason: "This sign-in did not start here. Try again from the site." };
|
|
110
|
+
}
|
|
111
|
+
const pending = JSON.parse(raw) as Pending;
|
|
112
|
+
|
|
113
|
+
// CSRF: the state must be the one this browser generated. Checked before the
|
|
114
|
+
// code is sent anywhere, so a planted code is never redeemed.
|
|
115
|
+
if (params.get("state") !== pending.state) {
|
|
116
|
+
return { ok: false, reason: "Sign-in could not be verified. Try again." };
|
|
117
|
+
}
|
|
118
|
+
const code = params.get("code");
|
|
119
|
+
if (code === null) return { ok: false, reason: "The issuer returned no authorization code." };
|
|
120
|
+
|
|
121
|
+
// No `credentials: "include"`: a public PKCE client sends no cookies, and
|
|
122
|
+
// sending them is refused outright by browsers against a wildcard CORS origin.
|
|
123
|
+
const endpoints = await discover(config.issuer);
|
|
124
|
+
if (endpoints === null) {
|
|
125
|
+
return { ok: false, reason: "The authorization server published no metadata document." };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await fetch(endpoints.token_endpoint, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
131
|
+
body: new URLSearchParams({
|
|
132
|
+
grant_type: "authorization_code",
|
|
133
|
+
code,
|
|
134
|
+
redirect_uri: config.redirectUri,
|
|
135
|
+
client_id: config.clientId,
|
|
136
|
+
code_verifier: pending.verifier,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
return { ok: false, reason: `The issuer refused the exchange (${response.status}).` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const token = (await response.json()) as TokenResponse;
|
|
144
|
+
const accessToken = str(token.access_token);
|
|
145
|
+
if (accessToken === null) return { ok: false, reason: "The issuer returned no access token." };
|
|
146
|
+
|
|
147
|
+
const claims = typeof token.id_token === "string" ? decodeClaims(token.id_token) : null;
|
|
148
|
+
const lifetime = typeof token.expires_in === "number" ? token.expires_in : 3600;
|
|
149
|
+
const session: Session = {
|
|
150
|
+
subject: str(claims?.sub) ?? "unknown",
|
|
151
|
+
name: str(claims?.name),
|
|
152
|
+
email: str(claims?.email),
|
|
153
|
+
accessToken,
|
|
154
|
+
expiresAt: Date.now() + lifetime * 1000,
|
|
155
|
+
};
|
|
156
|
+
writeSession(session);
|
|
157
|
+
return { ok: true, returnTo: pending.returnTo };
|
|
158
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 7636 (PKCE, S256) using the Web Crypto API — no dependency.
|
|
3
|
+
*
|
|
4
|
+
* PKCE is what lets a browser be an OAuth client with NO SECRET. The client
|
|
5
|
+
* generates a random verifier, sends only its SHA-256 hash to start the flow,
|
|
6
|
+
* and presents the verifier when redeeming the code. An attacker who steals the
|
|
7
|
+
* authorization code cannot redeem it without the verifier, which never left
|
|
8
|
+
* the browser that started the flow.
|
|
9
|
+
*
|
|
10
|
+
* That is the whole reason this site needs no server: there is nothing to keep
|
|
11
|
+
* secret, so there is nothing that must live somewhere the reader cannot see.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Base64url without padding — RFC 7636 §4.2 requires exactly this alphabet. */
|
|
15
|
+
function base64url(bytes: Uint8Array): string {
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
18
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function randomBase64url(byteLength: number): string {
|
|
22
|
+
const bytes = new Uint8Array(byteLength);
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
return base64url(bytes);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PkcePair {
|
|
28
|
+
readonly verifier: string;
|
|
29
|
+
readonly challenge: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A verifier and its S256 challenge. 32 bytes → 43 chars, the RFC's minimum. */
|
|
33
|
+
export async function createPkcePair(): Promise<PkcePair> {
|
|
34
|
+
const verifier = randomBase64url(32);
|
|
35
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
36
|
+
return { verifier, challenge: base64url(new Uint8Array(digest)) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** CSRF state, bound to this flow and checked when the issuer redirects back. */
|
|
40
|
+
export function createState(): string {
|
|
41
|
+
return randomBase64url(16);
|
|
42
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader's identity, for as long as the tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Storage is `sessionStorage`, deliberately, and it is the one decision here
|
|
5
|
+
* worth arguing about. The alternative — `localStorage` with long-lived tokens
|
|
6
|
+
* — is what the predecessor did and is what NOT to copy: a token readable by
|
|
7
|
+
* any script on the origin, surviving for days, for a feature that grants no
|
|
8
|
+
* access to anything on this site. The blast radius should match the benefit.
|
|
9
|
+
*
|
|
10
|
+
* So: per-tab, gone when the tab closes, and no refresh token is requested at
|
|
11
|
+
* all. Signing in again is one redirect through an issuer that already knows
|
|
12
|
+
* the reader; it is not worth holding a long-lived credential to avoid.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const KEY = "ksor.session";
|
|
16
|
+
|
|
17
|
+
export interface Session {
|
|
18
|
+
/** The `sub` claim — the issuer's stable identifier for this reader. */
|
|
19
|
+
readonly subject: string;
|
|
20
|
+
readonly name: string | null;
|
|
21
|
+
readonly email: string | null;
|
|
22
|
+
/**
|
|
23
|
+
* The issuer's access token. Held because a session without one is not a
|
|
24
|
+
* session — NOT because anything here calls an API with it yet. No audience
|
|
25
|
+
* is requested, so it is the issuer's own token and the door would refuse it.
|
|
26
|
+
*/
|
|
27
|
+
readonly accessToken: string;
|
|
28
|
+
/** Epoch milliseconds. A session past this is treated as absent. */
|
|
29
|
+
readonly expiresAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSession(value: unknown): value is Session {
|
|
33
|
+
if (typeof value !== "object" || value === null) return false;
|
|
34
|
+
const v = value as Record<string, unknown>;
|
|
35
|
+
return (
|
|
36
|
+
typeof v["subject"] === "string" &&
|
|
37
|
+
typeof v["accessToken"] === "string" &&
|
|
38
|
+
typeof v["expiresAt"] === "number" &&
|
|
39
|
+
(v["name"] === null || typeof v["name"] === "string") &&
|
|
40
|
+
(v["email"] === null || typeof v["email"] === "string")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The stored session, or null.
|
|
46
|
+
*
|
|
47
|
+
* Every failure returns null rather than throwing: storage can be unavailable
|
|
48
|
+
* (private windows, blocked site data), the value can be another version's
|
|
49
|
+
* shape, and none of those are worth breaking a page render over.
|
|
50
|
+
*/
|
|
51
|
+
export function readSession(): Session | null {
|
|
52
|
+
try {
|
|
53
|
+
const raw = sessionStorage.getItem(KEY);
|
|
54
|
+
if (raw === null) return null;
|
|
55
|
+
const parsed: unknown = JSON.parse(raw);
|
|
56
|
+
if (!isSession(parsed)) return null;
|
|
57
|
+
if (Date.now() >= parsed.expiresAt) {
|
|
58
|
+
sessionStorage.removeItem(KEY);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeSession(session: Session): void {
|
|
68
|
+
try {
|
|
69
|
+
sessionStorage.setItem(KEY, JSON.stringify(session));
|
|
70
|
+
} catch {
|
|
71
|
+
/* storage unavailable: the reader stays signed out, which is honest */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearSession(): void {
|
|
76
|
+
try {
|
|
77
|
+
sessionStorage.removeItem(KEY);
|
|
78
|
+
} catch {
|
|
79
|
+
/* nothing to do — a session that cannot be read cannot be used */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
|
2
|
+
|
|
3
|
+
import { SignIn } from "@/components/sign-in";
|
|
2
4
|
import { appTitle } from "./shared";
|
|
3
5
|
|
|
4
6
|
export function baseOptions(): BaseLayoutProps {
|
|
@@ -13,5 +15,9 @@ export function baseOptions(): BaseLayoutProps {
|
|
|
13
15
|
</span>
|
|
14
16
|
),
|
|
15
17
|
},
|
|
18
|
+
// `secondary` puts it at the navbar's trailing edge, beside the theme
|
|
19
|
+
// toggle. SignIn renders null when no issuer is configured, so a record
|
|
20
|
+
// that does not offer sign-in shows nothing rather than an empty slot.
|
|
21
|
+
links: [{ type: "custom", secondary: true, children: <SignIn /> }],
|
|
16
22
|
};
|
|
17
23
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMDX } from "fumadocs-mdx/next";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
@@ -10,6 +11,28 @@ const withMDX = createMDX();
|
|
|
10
11
|
// resolved from this file's own location so it holds wherever the repo lands.
|
|
11
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
13
|
|
|
14
|
+
// The record keeps ONE .env, at the repo root, because that is where `ksor`
|
|
15
|
+
// reads it. This build runs in system/site, so Next would never see it — and an
|
|
16
|
+
// adopter following the scaffold's own instructions would set NEXT_PUBLIC_*
|
|
17
|
+
// variables that silently never reach the bundle (found live). Read the root
|
|
18
|
+
// file here, and let a real environment variable win, which is the same
|
|
19
|
+
// precedence the CLI states.
|
|
20
|
+
function loadRootEnv() {
|
|
21
|
+
let contents;
|
|
22
|
+
try {
|
|
23
|
+
contents = readFileSync(path.join(repoRoot, ".env"), "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
return; // no .env is the normal case — only .env.example ships
|
|
26
|
+
}
|
|
27
|
+
for (const line of contents.split("\n")) {
|
|
28
|
+
const match = /^\s*(NEXT_PUBLIC_[A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
|
|
29
|
+
if (match?.[1] !== undefined && process.env[match[1]] === undefined) {
|
|
30
|
+
process.env[match[1]] = (match[2] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
loadRootEnv();
|
|
35
|
+
|
|
13
36
|
/** @type {import('next').NextConfig} */
|
|
14
37
|
const config = {
|
|
15
38
|
reactStrictMode: true,
|