@kanzo-tech/auth 0.29.0 → 0.30.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 +36 -23
- package/dist/bff-auth.d.ts +1 -2
- package/dist/bff-auth.d.ts.map +1 -1
- package/dist/bff-auth.js +82 -77
- package/dist/bff-auth.js.map +1 -1
- package/dist/can.d.ts +13 -9
- package/dist/can.d.ts.map +1 -1
- package/dist/can.js +6 -6
- package/dist/can.js.map +1 -1
- package/dist/gate.d.ts +4 -4
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +8 -9
- package/dist/gate.js.map +1 -1
- package/dist/index.d.ts +11 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -23
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +16 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +43 -25
- package/dist/issuer.js.map +1 -1
- package/dist/next-auth.d.ts +66 -0
- package/dist/next-auth.d.ts.map +1 -0
- package/dist/next-auth.js +43 -0
- package/dist/next-auth.js.map +1 -0
- package/dist/next-bound.d.ts +82 -0
- package/dist/next-bound.d.ts.map +1 -0
- package/dist/next-bound.js +27 -0
- package/dist/next-bound.js.map +1 -0
- package/dist/next-gate.d.ts +4 -0
- package/dist/next-gate.d.ts.map +1 -0
- package/dist/next-gate.js +74 -0
- package/dist/next-gate.js.map +1 -0
- package/dist/next-proxy.d.ts +15 -24
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +38 -33
- package/dist/next-proxy.js.map +1 -1
- package/dist/next-routes.d.ts +3 -21
- package/dist/next-routes.d.ts.map +1 -1
- package/dist/next-routes.js +93 -65
- package/dist/next-routes.js.map +1 -1
- package/dist/next.d.ts +16 -41
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +2 -10
- package/dist/next.js.map +1 -1
- package/dist/server.d.ts +60 -38
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +183 -159
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +102 -20
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +56 -21
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +40 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/use-session.d.ts +6 -1
- package/dist/use-session.d.ts.map +1 -1
- package/dist/use-session.js +9 -7
- package/dist/use-session.js.map +1 -1
- package/package.json +10 -29
- package/dist/auth-fetch.d.ts +0 -44
- package/dist/auth-fetch.d.ts.map +0 -1
- package/dist/auth-fetch.js +0 -21
- package/dist/auth-fetch.js.map +0 -1
- package/dist/browser.d.ts +0 -61
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js +0 -130
- package/dist/browser.js.map +0 -1
- package/dist/host.d.ts +0 -22
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -10
- package/dist/host.js.map +0 -1
- package/dist/next-middleware.d.ts +0 -23
- package/dist/next-middleware.d.ts.map +0 -1
- package/dist/next-middleware.js +0 -23
- package/dist/next-middleware.js.map +0 -1
- package/dist/next-session.d.ts +0 -44
- package/dist/next-session.d.ts.map +0 -1
- package/dist/next-session.js +0 -11
- package/dist/next-session.js.map +0 -1
- package/dist/next-token.d.ts +0 -56
- package/dist/next-token.d.ts.map +0 -1
- package/dist/next-token.js +0 -9
- package/dist/next-token.js.map +0 -1
- package/dist/use-organization.d.ts +0 -22
- package/dist/use-organization.d.ts.map +0 -1
- package/dist/use-organization.js +0 -20
- package/dist/use-organization.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @kanzo-tech/auth
|
|
2
2
|
|
|
3
|
-
Authentication over Keycloak:
|
|
4
|
-
|
|
3
|
+
Authentication over Keycloak: a Backend For Frontend for Next in one object, `kanzoAuth`, the claim
|
|
4
|
+
vocabulary read into one session, and the role evaluation that knows about organizations.
|
|
5
5
|
|
|
6
6
|
## What it is not
|
|
7
7
|
|
|
@@ -10,14 +10,15 @@ screen is a logo, a legal line, a privacy notice and a button, and every product
|
|
|
10
10
|
differently — `@kanzo-tech/ui` has the parts to draw one. What is genuinely shared sits underneath,
|
|
11
11
|
and that is what is here.
|
|
12
12
|
|
|
13
|
-
**There is no protocol in it either.**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
vulnerabilities, and
|
|
13
|
+
**There is no protocol in it either.** The confidential client is `openid-client`'s, behind
|
|
14
|
+
`./server`, and the browser holds a cookie and no token — RFC 10017's Backend For Frontend, the
|
|
15
|
+
architecture it recommends for business applications. Writing OAuth by hand is where mistakes turn
|
|
16
|
+
into vulnerabilities, and it is not what this package is for.
|
|
17
17
|
|
|
18
18
|
What is left after those two subtractions is everything this package is: **Keycloak's claims as one
|
|
19
|
-
`Session`, a role predicate that understands organizations,
|
|
20
|
-
and
|
|
19
|
+
`Session`, a role predicate that understands organizations, and the session lifecycle around them —
|
|
20
|
+
a proxy that renews and ends sessions, back-channel logout, and a browser half that signs in once
|
|
21
|
+
when a session is over.**
|
|
21
22
|
|
|
22
23
|
## Why a package, and not `@kanzo-tech/ui`
|
|
23
24
|
|
|
@@ -47,9 +48,18 @@ prising open a token.
|
|
|
47
48
|
pnpm add @kanzo-tech/auth
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
The root barrel carries no engine. The server doors name theirs — `openid-client` and `jose` for
|
|
52
|
+
`./server`, and `next` 16 or later as well for `./next` — and a consumer installs only what it
|
|
53
|
+
opens.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// lib/auth.ts
|
|
57
|
+
export const auth = kanzoAuth(async () => ({ issuer, clientId, clientSecret, secret, store }));
|
|
58
|
+
// proxy.ts
|
|
59
|
+
export const proxy = (request: NextRequest) => auth.proxy(request);
|
|
60
|
+
// app/api/auth/[...auth]/route.ts
|
|
61
|
+
export const { GET, POST } = auth.routes;
|
|
62
|
+
```
|
|
53
63
|
|
|
54
64
|
## The claims it reads
|
|
55
65
|
|
|
@@ -60,7 +70,8 @@ Nothing here is invented. Every claim is one Keycloak emits without being asked:
|
|
|
60
70
|
| `sub`, `email`, `name` (or `given_name` + `family_name`), `preferred_username` | `session.user` |
|
|
61
71
|
| `realm_access.roles` ∪ `resource_access.<clientId>.roles` | `session.roles` |
|
|
62
72
|
| `organization` — `{ "acme": { "id": "…", "resource_access": { "<clientId>": { "roles": ["editor"] } } } }` | `session.organizations` |
|
|
63
|
-
| `
|
|
73
|
+
| `sid` | the session record, for back-channel logout |
|
|
74
|
+
| — the token response's `expires_in` | `session.expiresAt`, in milliseconds: when the access token expires |
|
|
64
75
|
|
|
65
76
|
Inside each organization, `resource_access.<clientId>.roles` is what the person holds **there**:
|
|
66
77
|
the roles an organization admin mapped onto the groups they are in, composites expanded by
|
|
@@ -69,35 +80,37 @@ application's roles in the same entry are ignored, so a role held in one applica
|
|
|
69
80
|
authorises its holder in another. Nor are they merged into `session.roles`: a role in one
|
|
70
81
|
organization says nothing about the next.
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
`organization` returns the only one when there is one and prompts for a choice
|
|
74
|
-
several, which is the documented behaviour behind more than one bug report about the
|
|
75
|
-
"disappearing".
|
|
83
|
+
Every sign-in asks Keycloak for `organization:*`, which returns every organization the person
|
|
84
|
+
belongs to. Plain `organization` returns the only one when there is one and prompts for a choice
|
|
85
|
+
when there are several, which is the documented behaviour behind more than one bug report about the
|
|
86
|
+
claim "disappearing".
|
|
76
87
|
|
|
77
|
-
## Membership is
|
|
88
|
+
## Membership is stored; the current organization is resolved per request
|
|
78
89
|
|
|
79
90
|
```ts
|
|
80
91
|
interface Session {
|
|
81
92
|
user: AuthUser;
|
|
82
93
|
roles: readonly string[];
|
|
83
94
|
organizations: readonly Organization[];
|
|
95
|
+
organization?: string; // the tenant this request addresses
|
|
84
96
|
expiresAt: number;
|
|
85
97
|
}
|
|
86
98
|
```
|
|
87
99
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
be the single shared value they would fight over.
|
|
100
|
+
Membership is stable and comes from the token. Which organization a request is *in* is a property
|
|
101
|
+
of the request — its host, its path, a cookie — so `kanzoAuth`'s `organization` resolver answers it
|
|
102
|
+
per request and it is never stored. That is what lets two tabs sit in two organizations at once.
|
|
92
103
|
|
|
93
104
|
Roles held inside an organization live on that organization, and are never merged into
|
|
94
105
|
`session.roles`. Being an owner of one organization says nothing about another.
|
|
95
106
|
|
|
96
107
|
```ts
|
|
97
|
-
can(session, "
|
|
98
|
-
can(session, "owner", "acme"); //
|
|
108
|
+
can(session, "editor"); // inside session.organization, the current tenant
|
|
109
|
+
can(session, "owner", "acme"); // inside that organization
|
|
99
110
|
```
|
|
100
111
|
|
|
112
|
+
With no current tenant, `can` answers from the realm and client roles.
|
|
113
|
+
|
|
101
114
|
There is no role hierarchy in the predicate. That `owner` outranks `member` is a fact about a
|
|
102
115
|
product, so a product writes it where it can be seen:
|
|
103
116
|
|
package/dist/bff-auth.d.ts
CHANGED
|
@@ -8,8 +8,7 @@ import { Auth, Session } from './types';
|
|
|
8
8
|
*
|
|
9
9
|
* So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on
|
|
10
10
|
* the root barrel beside the hooks rather than behind a subpath. What it needs from the server is
|
|
11
|
-
*
|
|
12
|
-
* sign-out.
|
|
11
|
+
* the routes `kanzoAuth` serves: a session endpoint, a refresh, a sign-in and a sign-out.
|
|
13
12
|
*/
|
|
14
13
|
export interface BffAuthConfig {
|
|
15
14
|
/** Where the BFF's auth routes are mounted. Default `/api/auth`. */
|
package/dist/bff-auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bff-auth.d.ts","sourceRoot":"","sources":["../src/bff-auth.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"bff-auth.d.ts","sourceRoot":"","sources":["../src/bff-auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAa,KAAK,IAAI,EAAqB,KAAK,OAAO,EAAsB,MAAM,SAAS,CAAC;AAEpG;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AA8BD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CA6B1D;AAED,wBAAgB,OAAO,CAAC,MAAM,GAAE,aAAkB,GAAG,IAAI,CAgJxD"}
|
package/dist/bff-auth.js
CHANGED
|
@@ -1,116 +1,121 @@
|
|
|
1
|
-
import { isReplayable as $ } from "./auth-fetch.js";
|
|
2
1
|
import { deadline as p } from "./deadline.js";
|
|
3
|
-
import { singleFlight as
|
|
4
|
-
import { AuthError as
|
|
5
|
-
function
|
|
6
|
-
if (typeof
|
|
7
|
-
const
|
|
2
|
+
import { singleFlight as b } from "./single-flight.js";
|
|
3
|
+
import { AuthError as v } from "./types.js";
|
|
4
|
+
function z(a, r) {
|
|
5
|
+
if (typeof Request < "u" && a instanceof Request && a.body !== null) return !1;
|
|
6
|
+
const o = r == null ? void 0 : r.body;
|
|
7
|
+
return o == null ? !0 : !(typeof ReadableStream < "u" && o instanceof ReadableStream);
|
|
8
|
+
}
|
|
9
|
+
function T(a) {
|
|
10
|
+
if (typeof a != "object" || a === null) return null;
|
|
11
|
+
const r = a, o = r.alias;
|
|
8
12
|
return typeof o != "string" || o.length === 0 ? null : {
|
|
9
13
|
alias: o,
|
|
10
|
-
id: typeof
|
|
11
|
-
roles: Array.isArray(
|
|
14
|
+
id: typeof r.id == "string" ? r.id : void 0,
|
|
15
|
+
roles: Array.isArray(r.roles) ? r.roles.filter((i) => typeof i == "string") : []
|
|
12
16
|
};
|
|
13
17
|
}
|
|
14
|
-
function
|
|
15
|
-
if (typeof
|
|
16
|
-
const
|
|
18
|
+
function S(a) {
|
|
19
|
+
if (typeof a != "object" || a === null) return null;
|
|
20
|
+
const r = a, o = r.user;
|
|
17
21
|
if (typeof o != "object" || o === null) return null;
|
|
18
|
-
const
|
|
19
|
-
if (typeof
|
|
22
|
+
const i = o.id;
|
|
23
|
+
if (typeof i != "string" || i.length === 0) return null;
|
|
20
24
|
const l = o, c = (s) => typeof l[s] == "string" && l[s] !== "" ? l[s] : void 0;
|
|
21
25
|
return {
|
|
22
|
-
user: { id:
|
|
23
|
-
roles: Array.isArray(
|
|
24
|
-
organizations: Array.isArray(
|
|
25
|
-
|
|
26
|
+
user: { id: i, email: c("email"), name: c("name"), username: c("username") },
|
|
27
|
+
roles: Array.isArray(r.roles) ? r.roles.filter((s) => typeof s == "string") : [],
|
|
28
|
+
organizations: Array.isArray(r.organizations) ? r.organizations.map(T).filter((s) => s !== null) : [],
|
|
29
|
+
organization: typeof r.organization == "string" && r.organization !== "" ? r.organization : void 0,
|
|
30
|
+
expiresAt: typeof r.expiresAt == "number" ? r.expiresAt : 0
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
for (const
|
|
33
|
+
function q(a = {}) {
|
|
34
|
+
const r = (a.basePath ?? "/api/auth").replace(/\/$/, ""), o = a.fetch ?? ((...e) => globalThis.fetch(...e)), i = a.navigate ?? ((e) => void (globalThis.location.href = e)), l = /* @__PURE__ */ new Set(), c = () => {
|
|
35
|
+
for (const e of l) e();
|
|
31
36
|
};
|
|
32
|
-
let s = null,
|
|
33
|
-
const
|
|
37
|
+
let s = null, d = !1, g = !1;
|
|
38
|
+
const f = (e, n, t) => new v(
|
|
34
39
|
"session/unavailable",
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
),
|
|
39
|
-
var
|
|
40
|
+
n === void 0 ? `${e} could not be reached` : `${e} answered ${n}`,
|
|
41
|
+
n === void 0 ? {} : { status: n },
|
|
42
|
+
t === void 0 ? void 0 : { cause: t }
|
|
43
|
+
), y = async (e, n) => {
|
|
44
|
+
var t;
|
|
40
45
|
try {
|
|
41
|
-
return await o(
|
|
42
|
-
} catch (
|
|
43
|
-
throw (
|
|
46
|
+
return await o(e, n);
|
|
47
|
+
} catch (u) {
|
|
48
|
+
throw (t = n.signal) != null && t.aborted ? n.signal.reason : f(e, void 0, u);
|
|
44
49
|
}
|
|
45
|
-
}, m =
|
|
46
|
-
() => p("session/silent", async (
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
if (!
|
|
50
|
-
let
|
|
50
|
+
}, m = b(
|
|
51
|
+
() => p("session/silent", async (e) => {
|
|
52
|
+
const n = await y(`${r}/session`, { headers: { Accept: "application/json" }, signal: e });
|
|
53
|
+
if (n.status === 401) return null;
|
|
54
|
+
if (!n.ok) throw f(`${r}/session`, n.status);
|
|
55
|
+
let t;
|
|
51
56
|
try {
|
|
52
|
-
|
|
53
|
-
} catch (
|
|
54
|
-
throw
|
|
57
|
+
t = await n.json();
|
|
58
|
+
} catch (A) {
|
|
59
|
+
throw f(`${r}/session`, n.status, A);
|
|
55
60
|
}
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
61
|
+
const u = S(t);
|
|
62
|
+
if (u === null) throw f(`${r}/session`, n.status);
|
|
63
|
+
return u;
|
|
59
64
|
})
|
|
60
|
-
), w =
|
|
61
|
-
() => p("session/silent", async (
|
|
62
|
-
const
|
|
65
|
+
), w = b(
|
|
66
|
+
() => p("session/silent", async (e) => {
|
|
67
|
+
const n = await y(`${r}/refresh`, {
|
|
63
68
|
method: "POST",
|
|
64
69
|
headers: { Accept: "application/json" },
|
|
65
|
-
signal:
|
|
70
|
+
signal: e
|
|
66
71
|
});
|
|
67
|
-
if (
|
|
68
|
-
return
|
|
72
|
+
if (n.status >= 500) throw f(`${r}/refresh`, n.status);
|
|
73
|
+
return n.ok;
|
|
69
74
|
})
|
|
70
|
-
),
|
|
71
|
-
const
|
|
72
|
-
return s = await m(),
|
|
75
|
+
), $ = async () => {
|
|
76
|
+
const e = s;
|
|
77
|
+
return s = await m(), d = !0, (e === null != (s === null) || (e == null ? void 0 : e.user.id) !== (s == null ? void 0 : s.user.id)) && c(), s;
|
|
78
|
+
}, h = async (e = {}) => {
|
|
79
|
+
var t;
|
|
80
|
+
const n = new URLSearchParams();
|
|
81
|
+
n.set("returnTo", e.returnTo ?? ((t = globalThis.location) == null ? void 0 : t.href) ?? "/"), e.organization !== void 0 && n.set("organization", e.organization), i(`${r}/signin?${n.toString()}`);
|
|
73
82
|
};
|
|
74
83
|
return {
|
|
75
84
|
async getSession() {
|
|
76
|
-
return
|
|
77
|
-
},
|
|
78
|
-
subscribe(r) {
|
|
79
|
-
return l.add(r), () => l.delete(r);
|
|
85
|
+
return d ? s : $();
|
|
80
86
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const e = new URLSearchParams();
|
|
84
|
-
e.set("returnTo", r.returnTo ?? ((n = globalThis.location) == null ? void 0 : n.href) ?? "/"), r.organization !== void 0 && e.set("organization", r.organization), a(`${t}/signin?${e.toString()}`);
|
|
87
|
+
subscribe(e) {
|
|
88
|
+
return l.add(e), () => l.delete(e);
|
|
85
89
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
signIn: h,
|
|
91
|
+
async signOut(e = {}) {
|
|
92
|
+
const n = new URLSearchParams();
|
|
93
|
+
e.returnTo !== void 0 && n.set("returnTo", e.returnTo);
|
|
94
|
+
const t = n.toString();
|
|
95
|
+
i(t ? `${r}/signout?${t}` : `${r}/signout`);
|
|
91
96
|
},
|
|
92
97
|
/**
|
|
93
98
|
* No `Authorization` header — the cookie rides along on a same-origin request by itself — and
|
|
94
99
|
* **one** retry, behind one renewal.
|
|
95
100
|
*
|
|
96
|
-
* A 401 here is ambiguous
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
+
* A 401 here is ambiguous: the cookie was sent and was accepted, so what expired may be the
|
|
102
|
+
* access token *behind* the cookie, which this half of the pattern cannot see. So the 401 is
|
|
103
|
+
* taken as "renew and try again" first, and as "the session is over" only when the renewal is
|
|
104
|
+
* refused — and then the answer is to sign in, once, coming back to this page. One layer: a
|
|
105
|
+
* product's data client does not need a 401 branch of its own.
|
|
101
106
|
*
|
|
102
|
-
* The retry is once
|
|
103
|
-
*
|
|
104
|
-
* retried at all, and `isReplayable` is the same predicate the bearer-token path uses.
|
|
107
|
+
* The retry is once: twice turns an ended session into a loop against the authorization server.
|
|
108
|
+
* A request whose body cannot be replayed is not retried at all.
|
|
105
109
|
*/
|
|
106
|
-
fetch: async (
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
fetch: async (e, n) => {
|
|
111
|
+
var u;
|
|
112
|
+
const t = await o(e, n);
|
|
113
|
+
return t.status !== 401 || !d ? t : await w() ? z(e, n) ? o(e, n) : t : (g || (g = !0, await h({ returnTo: (u = globalThis.location) == null ? void 0 : u.href })), t);
|
|
109
114
|
}
|
|
110
115
|
};
|
|
111
116
|
}
|
|
112
117
|
export {
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
q as bffAuth,
|
|
119
|
+
S as readSession
|
|
115
120
|
};
|
|
116
121
|
//# sourceMappingURL=bff-auth.js.map
|
package/dist/bff-auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bff-auth.js","sources":["../src/bff-auth.ts"],"sourcesContent":["import { isReplayable } from \"./auth-fetch\";\nimport { deadline } from \"./deadline\";\nimport { singleFlight } from \"./single-flight\";\nimport { AuthError, type Auth, type Organization, type Session, type SignInOptions } from \"./types\";\n\n/**\n * The Backend-For-Frontend pattern: the token never reaches the browser.\n *\n * RFC 10017 calls this one *\"strongly recommended for business applications, sensitive\n * applications, and applications that handle personal data\"*. The server holds the confidential\n * client and the tokens; the browser holds a cookie it cannot read, and asks the server who it is.\n *\n * So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on\n * the root barrel beside the hooks rather than behind a subpath. What it needs from the server is\n * three routes, which `@kanzo-tech/auth/next` provides: a session endpoint, a sign-in and a\n * sign-out.\n */\n\nexport interface BffAuthConfig {\n /** Where the BFF's auth routes are mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /** Injectable for tests. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /** Injectable for tests. Defaults to assigning `window.location`. */\n readonly navigate?: (url: string) => void;\n}\n\nfunction readOrganization(value: unknown): Organization | null {\n if (typeof value !== \"object\" || value === null) return null;\n const org = value as Record<string, unknown>;\n const alias = org[\"alias\"];\n if (typeof alias !== \"string\" || alias.length === 0) return null;\n return {\n alias,\n id: typeof org[\"id\"] === \"string\" ? org[\"id\"] : undefined,\n roles: Array.isArray(org[\"roles\"])\n ? org[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n };\n}\n\n/**\n * The session endpoint's answer, checked rather than cast.\n *\n * It is our own server on the other end, and it is still **data off the wire**: a cast here would\n * make a deploy skew or a proxy's error page arrive as a `Session` whose `user` is undefined, and\n * the failure would surface three components away as a property read on nothing. `null` for\n * anything unrecognisable; `bffAuth` reads a 200 it cannot read as `session/unavailable`, because\n * the endpoint answers \"nobody is signed in\" with a 401 and never with a body.\n */\nexport function readSession(value: unknown): Session | null {\n if (typeof value !== \"object\" || value === null) return null;\n const body = value as Record<string, unknown>;\n\n const user = body[\"user\"];\n if (typeof user !== \"object\" || user === null) return null;\n const id = (user as Record<string, unknown>)[\"id\"];\n if (typeof id !== \"string\" || id.length === 0) return null;\n\n const person = user as Record<string, unknown>;\n const str = (key: string) =>\n typeof person[key] === \"string\" && person[key] !== \"\" ? (person[key] as string) : undefined;\n\n return {\n user: { id, email: str(\"email\"), name: str(\"name\"), username: str(\"username\") },\n roles: Array.isArray(body[\"roles\"])\n ? body[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n organizations: Array.isArray(body[\"organizations\"])\n ? body[\"organizations\"]\n .map(readOrganization)\n .filter((o): o is Organization => o !== null)\n : [],\n expiresAt: typeof body[\"expiresAt\"] === \"number\" ? body[\"expiresAt\"] : 0,\n };\n}\n\nexport function bffAuth(config: BffAuthConfig = {}): Auth {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const doFetch = config.fetch ?? ((...args) => globalThis.fetch(...args));\n const go = config.navigate ?? ((url: string) => void (globalThis.location.href = url));\n\n const listeners = new Set<() => void>();\n const announce = () => {\n for (const listener of listeners) listener();\n };\n\n let cached: Session | null = null;\n let known = false;\n\n const unavailable = (url: string, status: number | undefined, cause?: unknown) =>\n new AuthError(\n \"session/unavailable\",\n status === undefined ? `${url} could not be reached` : `${url} answered ${status}`,\n status === undefined ? {} : { status },\n cause === undefined ? undefined : { cause },\n );\n\n const send = async (url: string, init: RequestInit): Promise<Response> => {\n try {\n return await doFetch(url, init);\n } catch (error) {\n if (init.signal?.aborted) throw init.signal.reason;\n throw unavailable(url, undefined, error);\n }\n };\n\n // Single-flight for the same reason the token path needs it: a page that mounts six components\n // asks six times in one tick, and one answer serves them all. Here it costs a request rather\n // than a revoked token chain, which is a smaller bill for the same mistake.\n //\n // A 401 is the documented answer for \"nobody is signed in\". Anything else that is not a session\n // — a 5xx, a proxy's page, no answer at all — is a failure, and reading it as \"signed out\" would\n // send the person to sign in over an outage that will still be there when they get back.\n const read = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<Session | null> => {\n const response = await send(`${base}/session`, { headers: { Accept: \"application/json\" }, signal });\n if (response.status === 401) return null;\n if (!response.ok) throw unavailable(`${base}/session`, response.status);\n let body: unknown;\n try {\n body = await response.json();\n } catch (error) {\n throw unavailable(`${base}/session`, response.status, error);\n }\n const session = readSession(body);\n if (session === null) throw unavailable(`${base}/session`, response.status);\n return session;\n }),\n );\n\n /**\n * Ask the BFF to spend the refresh token, once for however many requests noticed at the moment.\n *\n * This is the browser end of the renewal, and without it the session cookie's lifetime and the\n * access token's are two different clocks with nothing between them: a cookie good for eight\n * hours in front of a token good for one produces seven hours in which `/session` answers 200,\n * the whole application draws, and every request for data is a 401 that nothing acts on.\n *\n * `POST`, because the route only answers `POST` — it spends something, and a `GET` that spends\n * something is one prefetch away from spending it unasked.\n */\n const renew = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<boolean> => {\n const response = await send(`${base}/refresh`, {\n method: \"POST\",\n headers: { Accept: \"application/json\" },\n signal,\n });\n // A refusal (401, 400) is the end of the session; a 5xx is an outage, not a refusal.\n if (response.status >= 500) throw unavailable(`${base}/refresh`, response.status);\n return response.ok;\n }),\n );\n\n const refresh = async (): Promise<Session | null> => {\n const next = cached;\n cached = await read();\n known = true;\n // Announce only a change, so a poll does not re-render the tree every time.\n if ((next === null) !== (cached === null) || next?.user.id !== cached?.user.id) announce();\n return cached;\n };\n\n return {\n async getSession() {\n if (known) return cached;\n return refresh();\n },\n\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n\n async signIn(options: SignInOptions = {}) {\n const params = new URLSearchParams();\n params.set(\"returnTo\", options.returnTo ?? globalThis.location?.href ?? \"/\");\n if (options.organization !== undefined) params.set(\"organization\", options.organization);\n go(`${base}/signin?${params.toString()}`);\n },\n\n async signOut(options = {}) {\n const params = new URLSearchParams();\n if (options.returnTo !== undefined) params.set(\"returnTo\", options.returnTo);\n const query = params.toString();\n go(query ? `${base}/signout?${query}` : `${base}/signout`);\n },\n\n /**\n * No `Authorization` header — the cookie rides along on a same-origin request by itself — and\n * **one** retry, behind one renewal.\n *\n * A 401 here is ambiguous in a way it is not under `browserAuth`: the cookie was sent and was\n * accepted, so what expired is the access token *behind* the cookie, which this half of the\n * pattern cannot see. So the 401 is taken as \"renew and try again\" first and as \"the session\n * is gone\" only when the renewal is refused — at which point re-reading tells the tree, which\n * is what it did before and all it did before.\n *\n * The retry is once, for the reason `authFetch` gives: twice turns an ended session into a\n * loop against the authorization server. A request whose body cannot be replayed is not\n * retried at all, and `isReplayable` is the same predicate the bearer-token path uses.\n */\n fetch: async (input, init) => {\n const response = await doFetch(input, init);\n if (response.status !== 401 || !known) return response;\n\n if (await renew()) {\n if (!isReplayable(input, init)) return response;\n return doFetch(input, init);\n }\n\n known = false;\n // The caller is owed its own response; a session that cannot be re-read is the provider's to\n // show, and announcing makes it read again and see the failure itself.\n await refresh().catch(announce);\n return response;\n },\n };\n}\n"],"names":["readOrganization","value","org","alias","r","readSession","body","user","id","person","str","key","o","bffAuth","config","base","doFetch","args","go","url","listeners","announce","listener","cached","known","unavailable","status","cause","AuthError","send","init","error","_a","read","singleFlight","deadline","signal","response","session","renew","refresh","next","onChange","options","params","query","input","isReplayable"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bff-auth.js","sources":["../src/bff-auth.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport { singleFlight } from \"./single-flight\";\nimport { AuthError, type Auth, type Organization, type Session, type SignInOptions } from \"./types\";\n\n/**\n * The Backend-For-Frontend pattern: the token never reaches the browser.\n *\n * RFC 10017 calls this one *\"strongly recommended for business applications, sensitive\n * applications, and applications that handle personal data\"*. The server holds the confidential\n * client and the tokens; the browser holds a cookie it cannot read, and asks the server who it is.\n *\n * So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on\n * the root barrel beside the hooks rather than behind a subpath. What it needs from the server is\n * the routes `kanzoAuth` serves: a session endpoint, a refresh, a sign-in and a sign-out.\n */\n\nexport interface BffAuthConfig {\n /** Where the BFF's auth routes are mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /** Injectable for tests. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /** Injectable for tests. Defaults to assigning `window.location`. */\n readonly navigate?: (url: string) => void;\n}\n\n/**\n * Can this request be sent a second time?\n *\n * A body that is a stream can be read once, so a retry would send an empty one — silently, with a\n * misleading error at the far end. Where we cannot prove the body is replayable we do not retry: the\n * 401 reaches the caller, which is honest, rather than a corrupted request reaching the server.\n */\nfunction isReplayable(input: RequestInfo | URL, init?: RequestInit): boolean {\n if (typeof Request !== \"undefined\" && input instanceof Request && input.body !== null) return false;\n const body = init?.body;\n if (body === undefined || body === null) return true;\n return !(typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream);\n}\n\nfunction readOrganization(value: unknown): Organization | null {\n if (typeof value !== \"object\" || value === null) return null;\n const org = value as Record<string, unknown>;\n const alias = org[\"alias\"];\n if (typeof alias !== \"string\" || alias.length === 0) return null;\n return {\n alias,\n id: typeof org[\"id\"] === \"string\" ? org[\"id\"] : undefined,\n roles: Array.isArray(org[\"roles\"])\n ? org[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n };\n}\n\n/**\n * The session endpoint's answer, checked rather than cast.\n *\n * It is our own server on the other end, and it is still **data off the wire**: a cast here would\n * make a deploy skew or a proxy's error page arrive as a `Session` whose `user` is undefined, and\n * the failure would surface three components away as a property read on nothing. `null` for\n * anything unrecognisable; `bffAuth` reads a 200 it cannot read as `session/unavailable`, because\n * the endpoint answers \"nobody is signed in\" with a 401 and never with a body.\n */\nexport function readSession(value: unknown): Session | null {\n if (typeof value !== \"object\" || value === null) return null;\n const body = value as Record<string, unknown>;\n\n const user = body[\"user\"];\n if (typeof user !== \"object\" || user === null) return null;\n const id = (user as Record<string, unknown>)[\"id\"];\n if (typeof id !== \"string\" || id.length === 0) return null;\n\n const person = user as Record<string, unknown>;\n const str = (key: string) =>\n typeof person[key] === \"string\" && person[key] !== \"\" ? (person[key] as string) : undefined;\n\n return {\n user: { id, email: str(\"email\"), name: str(\"name\"), username: str(\"username\") },\n roles: Array.isArray(body[\"roles\"])\n ? body[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n organizations: Array.isArray(body[\"organizations\"])\n ? body[\"organizations\"]\n .map(readOrganization)\n .filter((o): o is Organization => o !== null)\n : [],\n organization:\n typeof body[\"organization\"] === \"string\" && body[\"organization\"] !== \"\"\n ? body[\"organization\"]\n : undefined,\n expiresAt: typeof body[\"expiresAt\"] === \"number\" ? body[\"expiresAt\"] : 0,\n };\n}\n\nexport function bffAuth(config: BffAuthConfig = {}): Auth {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const doFetch = config.fetch ?? ((...args) => globalThis.fetch(...args));\n const go = config.navigate ?? ((url: string) => void (globalThis.location.href = url));\n\n const listeners = new Set<() => void>();\n const announce = () => {\n for (const listener of listeners) listener();\n };\n\n let cached: Session | null = null;\n let known = false;\n /** Set by the one sign-in a refused renewal starts; the page is leaving, so it is never unset. */\n let leaving = false;\n\n const unavailable = (url: string, status: number | undefined, cause?: unknown) =>\n new AuthError(\n \"session/unavailable\",\n status === undefined ? `${url} could not be reached` : `${url} answered ${status}`,\n status === undefined ? {} : { status },\n cause === undefined ? undefined : { cause },\n );\n\n const send = async (url: string, init: RequestInit): Promise<Response> => {\n try {\n return await doFetch(url, init);\n } catch (error) {\n if (init.signal?.aborted) throw init.signal.reason;\n throw unavailable(url, undefined, error);\n }\n };\n\n // Single-flight for the same reason the token path needs it: a page that mounts six components\n // asks six times in one tick, and one answer serves them all. Here it costs a request rather\n // than a revoked token chain, which is a smaller bill for the same mistake.\n //\n // A 401 is the documented answer for \"nobody is signed in\". Anything else that is not a session\n // — a 5xx, a proxy's page, no answer at all — is a failure, and reading it as \"signed out\" would\n // send the person to sign in over an outage that will still be there when they get back.\n const read = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<Session | null> => {\n const response = await send(`${base}/session`, { headers: { Accept: \"application/json\" }, signal });\n if (response.status === 401) return null;\n if (!response.ok) throw unavailable(`${base}/session`, response.status);\n let body: unknown;\n try {\n body = await response.json();\n } catch (error) {\n throw unavailable(`${base}/session`, response.status, error);\n }\n const session = readSession(body);\n if (session === null) throw unavailable(`${base}/session`, response.status);\n return session;\n }),\n );\n\n /**\n * Ask the BFF to spend the refresh token, once for however many requests noticed at the moment.\n *\n * The proxy renews before every page and the forwarder before every request it forwards, so this\n * is the last of three: a 401 that reached the browser anyway, from a resource the BFF does not\n * front or a token that died between the forwarder's check and the upstream's.\n *\n * `POST`, because the route only answers `POST` — it spends something, and a `GET` that spends\n * something is one prefetch away from spending it unasked.\n */\n const renew = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<boolean> => {\n const response = await send(`${base}/refresh`, {\n method: \"POST\",\n headers: { Accept: \"application/json\" },\n signal,\n });\n // A refusal (401, 400) is the end of the session; a 5xx is an outage, not a refusal.\n if (response.status >= 500) throw unavailable(`${base}/refresh`, response.status);\n return response.ok;\n }),\n );\n\n const refresh = async (): Promise<Session | null> => {\n const next = cached;\n cached = await read();\n known = true;\n // Announce only a change, so a poll does not re-render the tree every time.\n if ((next === null) !== (cached === null) || next?.user.id !== cached?.user.id) announce();\n return cached;\n };\n\n const signIn = async (options: SignInOptions = {}) => {\n const params = new URLSearchParams();\n params.set(\"returnTo\", options.returnTo ?? globalThis.location?.href ?? \"/\");\n if (options.organization !== undefined) params.set(\"organization\", options.organization);\n go(`${base}/signin?${params.toString()}`);\n };\n\n return {\n async getSession() {\n if (known) return cached;\n return refresh();\n },\n\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n\n signIn,\n\n async signOut(options = {}) {\n const params = new URLSearchParams();\n if (options.returnTo !== undefined) params.set(\"returnTo\", options.returnTo);\n const query = params.toString();\n go(query ? `${base}/signout?${query}` : `${base}/signout`);\n },\n\n /**\n * No `Authorization` header — the cookie rides along on a same-origin request by itself — and\n * **one** retry, behind one renewal.\n *\n * A 401 here is ambiguous: the cookie was sent and was accepted, so what expired may be the\n * access token *behind* the cookie, which this half of the pattern cannot see. So the 401 is\n * taken as \"renew and try again\" first, and as \"the session is over\" only when the renewal is\n * refused — and then the answer is to sign in, once, coming back to this page. One layer: a\n * product's data client does not need a 401 branch of its own.\n *\n * The retry is once: twice turns an ended session into a loop against the authorization server.\n * A request whose body cannot be replayed is not retried at all.\n */\n fetch: async (input, init) => {\n const response = await doFetch(input, init);\n if (response.status !== 401 || !known) return response;\n\n if (await renew()) {\n if (!isReplayable(input, init)) return response;\n return doFetch(input, init);\n }\n\n if (!leaving) {\n leaving = true;\n await signIn({ returnTo: globalThis.location?.href });\n }\n return response;\n },\n };\n}\n"],"names":["isReplayable","input","init","body","readOrganization","value","org","alias","r","readSession","user","id","person","str","key","o","bffAuth","config","base","doFetch","args","go","url","listeners","announce","listener","cached","known","leaving","unavailable","status","cause","AuthError","send","error","_a","read","singleFlight","deadline","signal","response","session","renew","refresh","next","signIn","options","params","onChange","query"],"mappings":"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"}
|
package/dist/can.d.ts
CHANGED
|
@@ -10,17 +10,21 @@ import { Organization, Session } from './types';
|
|
|
10
10
|
/** The organization by that alias, or `undefined` for one this person does not belong to. */
|
|
11
11
|
export declare function organizationOf(session: Session | null | undefined, alias: string): Organization | undefined;
|
|
12
12
|
/**
|
|
13
|
-
* Does this session hold `role
|
|
13
|
+
* Does this session hold `role` — in the organization this request addresses, unless told which?
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* The organization defaults to `session.organization`, the current tenant the product's resolver
|
|
16
|
+
* named, so `can(session, "editor")` asks the question a page means: *here*. Clerk's
|
|
17
|
+
* `auth().has()` binds to the active organization the same way. Named explicitly, the question is
|
|
18
|
+
* asked inside that one instead. Inside an organization the answer comes from the person's roles
|
|
19
|
+
* there, which is a different set from their realm and client roles and is deliberately not
|
|
20
|
+
* merged with them: a role held in one organization says nothing about another, and the day those
|
|
21
|
+
* two sets are unioned for convenience is the day one organization's owner is every
|
|
22
|
+
* organization's owner. With no tenant at all, the realm and client roles answer.
|
|
19
23
|
*
|
|
20
|
-
* Closed by default: no session, or no membership of the
|
|
21
|
-
*
|
|
24
|
+
* Closed by default: no session, or no membership of the organization, is `false` rather than an
|
|
25
|
+
* error. There is no hierarchy here either: that `admin` contains `editor` is a fact about a
|
|
22
26
|
* product, declared once as Keycloak composite roles where the product registers its client, and
|
|
23
|
-
* the token carries the expanded set — so `can(s, "editor"
|
|
27
|
+
* the token carries the expanded set — so `can(s, "editor")` is true for an admin.
|
|
24
28
|
*/
|
|
25
|
-
export declare function can(session: Session | null | undefined, role: string, organization?: string): boolean;
|
|
29
|
+
export declare function can(session: Session | null | undefined, role: string, organization?: string | undefined): boolean;
|
|
26
30
|
//# sourceMappingURL=can.d.ts.map
|
package/dist/can.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"can.d.ts","sourceRoot":"","sources":["../src/can.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD;;;;;;;GAOG;AAEH,6FAA6F;AAC7F,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GACZ,YAAY,GAAG,SAAS,CAE1B;AAED
|
|
1
|
+
{"version":3,"file":"can.d.ts","sourceRoot":"","sources":["../src/can.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD;;;;;;;GAOG;AAEH,6FAA6F;AAC7F,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GACZ,YAAY,GAAG,SAAS,CAE1B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,IAAI,EAAE,MAAM,EACZ,YAAY,GAAE,MAAM,GAAG,SAAiC,GACvD,OAAO,CAIT"}
|
package/dist/can.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
function u(r,
|
|
2
|
-
return r == null ? void 0 : r.organizations.find((
|
|
1
|
+
function u(r, a) {
|
|
2
|
+
return r == null ? void 0 : r.organizations.find((t) => t.alias === a);
|
|
3
3
|
}
|
|
4
|
-
function
|
|
5
|
-
var
|
|
6
|
-
return r ?
|
|
4
|
+
function l(r, a, t = r == null ? void 0 : r.organization) {
|
|
5
|
+
var f;
|
|
6
|
+
return r ? t === void 0 ? r.roles.includes(a) : ((f = u(r, t)) == null ? void 0 : f.roles.includes(a)) ?? !1 : !1;
|
|
7
7
|
}
|
|
8
8
|
export {
|
|
9
|
-
|
|
9
|
+
l as can,
|
|
10
10
|
u as organizationOf
|
|
11
11
|
};
|
|
12
12
|
//# sourceMappingURL=can.js.map
|
package/dist/can.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"can.js","sources":["../src/can.ts"],"sourcesContent":["import type { Organization, Session } from \"./types\";\n\n/**\n * The role predicate, and the lookup underneath it.\n *\n * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,\n * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a\n * button, and the resource server — validating the access token it was sent — is what actually\n * refuses the request behind it. A product that gates only here has not gated anything.\n */\n\n/** The organization by that alias, or `undefined` for one this person does not belong to. */\nexport function organizationOf(\n session: Session | null | undefined,\n alias: string,\n): Organization | undefined {\n return session?.organizations.find((org) => org.alias === alias);\n}\n\n/**\n * Does this session hold `role
|
|
1
|
+
{"version":3,"file":"can.js","sources":["../src/can.ts"],"sourcesContent":["import type { Organization, Session } from \"./types\";\n\n/**\n * The role predicate, and the lookup underneath it.\n *\n * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,\n * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a\n * button, and the resource server — validating the access token it was sent — is what actually\n * refuses the request behind it. A product that gates only here has not gated anything.\n */\n\n/** The organization by that alias, or `undefined` for one this person does not belong to. */\nexport function organizationOf(\n session: Session | null | undefined,\n alias: string,\n): Organization | undefined {\n return session?.organizations.find((org) => org.alias === alias);\n}\n\n/**\n * Does this session hold `role` — in the organization this request addresses, unless told which?\n *\n * The organization defaults to `session.organization`, the current tenant the product's resolver\n * named, so `can(session, \"editor\")` asks the question a page means: *here*. Clerk's\n * `auth().has()` binds to the active organization the same way. Named explicitly, the question is\n * asked inside that one instead. Inside an organization the answer comes from the person's roles\n * there, which is a different set from their realm and client roles and is deliberately not\n * merged with them: a role held in one organization says nothing about another, and the day those\n * two sets are unioned for convenience is the day one organization's owner is every\n * organization's owner. With no tenant at all, the realm and client roles answer.\n *\n * Closed by default: no session, or no membership of the organization, is `false` rather than an\n * error. There is no hierarchy here either: that `admin` contains `editor` is a fact about a\n * product, declared once as Keycloak composite roles where the product registers its client, and\n * the token carries the expanded set — so `can(s, \"editor\")` is true for an admin.\n */\nexport function can(\n session: Session | null | undefined,\n role: string,\n organization: string | undefined = session?.organization,\n): boolean {\n if (!session) return false;\n if (organization === undefined) return session.roles.includes(role);\n return organizationOf(session, organization)?.roles.includes(role) ?? false;\n}\n"],"names":["organizationOf","session","alias","org","can","role","organization","_a"],"mappings":"AAYO,SAASA,EACdC,GACAC,GAC0B;AAC1B,SAAOD,KAAA,gBAAAA,EAAS,cAAc,KAAK,CAACE,MAAQA,EAAI,UAAUD;AAC5D;AAmBO,SAASE,EACdH,GACAI,GACAC,IAAmCL,KAAA,gBAAAA,EAAS,cACnC;AA5BJ,MAAAM;AA6BL,SAAKN,IACDK,MAAiB,SAAkBL,EAAQ,MAAM,SAASI,CAAI,MAC3DE,IAAAP,EAAeC,GAASK,CAAY,MAApC,gBAAAC,EAAuC,MAAM,SAASF,OAAS,KAFjD;AAGvB;"}
|
package/dist/gate.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Show `children` to someone who holds `role`, and `fallback` to everyone else.
|
|
3
3
|
*
|
|
4
|
-
* **This hides UI and protects nothing**, for the reason
|
|
5
|
-
*
|
|
4
|
+
* **This hides UI and protects nothing**, for the reason `can` carries: a product gated only here is
|
|
5
|
+
* ungated.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The question is asked inside the current tenant, `session.organization`, unless `organization`
|
|
8
|
+
* names another — the same default as `useSession().can`.
|
|
9
9
|
*/
|
|
10
10
|
export declare function Gate({ role, organization, fallback, children, }: {
|
|
11
11
|
readonly role: string;
|
package/dist/gate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAI,EACJ,YAAY,EACZ,QAAe,EACf,QAAQ,GACT,EAAE;IACD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,sCAUA"}
|
package/dist/gate.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as
|
|
3
|
-
import { can as m } from "./can.js";
|
|
2
|
+
import { jsx as l, Fragment as s } from "react/jsx-runtime";
|
|
4
3
|
import { useSession as u } from "./use-session.js";
|
|
5
|
-
function
|
|
4
|
+
function m({
|
|
6
5
|
role: r,
|
|
7
|
-
organization:
|
|
8
|
-
fallback:
|
|
9
|
-
children:
|
|
6
|
+
organization: t,
|
|
7
|
+
fallback: e = null,
|
|
8
|
+
children: o
|
|
10
9
|
}) {
|
|
11
|
-
const {
|
|
12
|
-
return n === "loading" || n === "failed" ? null : /* @__PURE__ */ s
|
|
10
|
+
const { can: i, status: n } = u();
|
|
11
|
+
return n === "loading" || n === "failed" ? null : /* @__PURE__ */ l(s, { children: i(r, t) ? o : e });
|
|
13
12
|
}
|
|
14
13
|
export {
|
|
15
|
-
|
|
14
|
+
m as Gate
|
|
16
15
|
};
|
|
17
16
|
//# sourceMappingURL=gate.js.map
|
package/dist/gate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport {
|
|
1
|
+
{"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason `can` carries: a product gated only here is\n * ungated.\n *\n * The question is asked inside the current tenant, `session.organization`, unless `organization`\n * names another — the same default as `useSession().can`.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { can, status } = useSession();\n\n // While the session is still being read, or could not be, neither answer is known to be true, so\n // neither is drawn. Rendering the fallback here is the flicker worth avoiding — \"you cannot do\n // this\" shown to someone who can, for as long as the session endpoint takes — and rendering the\n // children is worse, because it flashes a control and then retracts it.\n if (status === \"loading\" || status === \"failed\") return null;\n\n return <>{can(role, organization) ? children : fallback}</>;\n}\n"],"names":[],"mappings":";;;AAaO;AAAc;AACnB;AACA;AACW;AAEb;AAME;AAMA;AAGF;;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* those differently. What is genuinely shared sits underneath — reading Keycloak's claims into one
|
|
16
16
|
* session, evaluating a role inside an organization, and keeping a `fetch` authenticated.
|
|
17
17
|
*
|
|
18
|
-
* **There is no protocol in it either.**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* **There is no protocol in it either.** The confidential client is `openid-client`'s, behind
|
|
19
|
+
* `./server`, and the browser holds no token at all — RFC 10017's Backend For Frontend, the one
|
|
20
|
+
* architecture it recommends for business applications. Writing OAuth by hand is where mistakes
|
|
21
|
+
* become vulnerabilities.
|
|
21
22
|
*
|
|
22
23
|
* ## The one rule to read before using it
|
|
23
24
|
*
|
|
@@ -27,21 +28,15 @@
|
|
|
27
28
|
*
|
|
28
29
|
* ## What a name means here
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
* of these were added in parallel and drifted into two conventions and one exception:
|
|
31
|
+
* Two families, and the shape of the name says which one a thing is:
|
|
32
32
|
*
|
|
33
|
-
* - **`<
|
|
34
|
-
*
|
|
35
|
-
* - **`auth<Thing>`** returns an auth-flavoured *thing* — `authFetch` a `fetch`, and on `./next`
|
|
36
|
-
* `authRoutes`, `authSession`, `authMiddleware`.
|
|
33
|
+
* - **`<where>Auth`** is a whole side of the BFF in one object — `bffAuth` in the browser, an
|
|
34
|
+
* {@link Auth}; `kanzoAuth` on `./next`, the server half for an App Router product.
|
|
37
35
|
* - **Everything else is named for what it is**: `relyingParty`, `issuer`, `sealedCookie`, `claims`,
|
|
38
|
-
* `can`. The first of those used to be `serverAuth`, which wore the suffix without
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* *What would reverse it:* a fourth family. Two exist because two patterns exist; a third convention
|
|
44
|
-
* would mean the vocabulary has outgrown the rule rather than that the rule needs an exception.
|
|
36
|
+
* `can`. The first of those used to be `serverAuth`, which wore the suffix without being a side
|
|
37
|
+
* of anything — its job is `begin`/`complete`/`read`/`refresh`/`end`, the protocol's verbs. A
|
|
38
|
+
* name that promises an interface it does not return is worse than a long one, and *relying
|
|
39
|
+
* party* is the term the specification already uses for it.
|
|
45
40
|
*
|
|
46
41
|
* ## This door carries no engine
|
|
47
42
|
*
|
|
@@ -50,16 +45,13 @@
|
|
|
50
45
|
* server there is no protocol left in the browser, only a `fetch` to a session endpoint.
|
|
51
46
|
*/
|
|
52
47
|
export { accountUrl, type AccountPage } from './account';
|
|
53
|
-
export { authFetch, type TokenSource } from './auth-fetch';
|
|
54
48
|
export type { AuthContextValue, AuthStatus } from './auth-context';
|
|
55
49
|
export { AuthProvider } from './auth-provider';
|
|
56
50
|
export { bffAuth, readSession, type BffAuthConfig } from './bff-auth';
|
|
57
51
|
export { can, organizationOf } from './can';
|
|
58
52
|
export { claims, type ClaimsConfig } from './claims';
|
|
59
53
|
export { Gate } from './gate';
|
|
60
|
-
export { organizationFromHost } from './host';
|
|
61
54
|
export { singleFlight } from './single-flight';
|
|
62
55
|
export { AuthError, type Auth, type AuthErrorCode, type AuthUser, type Organization, type Session, type SignInOptions, } from './types';
|
|
63
|
-
export { useOrganization } from './use-organization';
|
|
64
56
|
export { useSession } from './use-session';
|
|
65
57
|
//# sourceMappingURL=index.d.ts.map
|