@eventuras/fides-auth 0.12.0 → 0.13.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.
@@ -0,0 +1,189 @@
1
+ # Session events
2
+
3
+ Every logout looks the same from the outside: the user says "I had to log in
4
+ again". This page describes the events fides-auth emits so a production operator
5
+ can tell *which* path ended a session — without reading library source.
6
+
7
+ ## The vocabulary
8
+
9
+ Event names are `<area>.<outcome>`, dotted and past tense. Dotted so a dashboard
10
+ can match a whole area with one prefix (`event=~"session\\..*"`); past tense
11
+ because every event records something that already happened.
12
+
13
+ The name is a **field**, not the message. Query on `event`, not on free text —
14
+ messages are for humans and may be reworded, names are the contract.
15
+
16
+ | `event` | Level | Always present | May be absent |
17
+ | ------------------------ | ----------------- | -------------------- | ----------------------------------------------- |
18
+ | `session.created` | info | `hasRefreshToken` | `sid`, `scopes`, `expiresIn`, `cookieBytes` |
19
+ | `session.refreshed` | info | `rotatedRefreshToken`| `sid`, `expiresIn`, `cookieBytes` |
20
+ | `session.rejected` | per `reason` | `reason` | `sid`, `source` |
21
+ | `session.refresh_failed` | per `cause` | `cause` | `sid`, `status`, `accessTokenExpiresAt` |
22
+ | `session.cleared` | info | `trigger` | `sid` |
23
+
24
+ A "may be absent" field is *omitted* when unknown, never logged as `null` —
25
+ `logSessionEvent` drops undefined values, so an absent field costs the log
26
+ backend nothing. Write queries that tolerate absence.
27
+
28
+ `sid` may be absent on every event: sessions created before `Session.sid` existed
29
+ carry none, and a request rejected for `no_session_cookie` or
30
+ `unreadable_session` never had a session to take one from. It fills in for
31
+ everyone after one re-login.
32
+
33
+ Import the names rather than typing them, so a rename is a compile error instead
34
+ of a silently dead dashboard:
35
+
36
+ ```ts
37
+ import { SESSION_EVENT } from "@eventuras/fides-auth/session-events";
38
+ ```
39
+
40
+ ### `reason` — why a session could not be used
41
+
42
+ One vocabulary for the whole package. A proxy, a status route and the heartbeat
43
+ endpoint all report the same word for the same situation.
44
+
45
+ | `reason` | Level | Means |
46
+ | ---------------------- | ----- | -------------------------------------------------------------- |
47
+ | `no_session_cookie` | debug | No cookie was sent. The ordinary anonymous case. |
48
+ | `stale_legacy_session` | info | A pre-split single-cookie session. Costs one re-login. |
49
+ | `refresh_failed` | info | The provider rejected the refresh token. See `cause`. |
50
+ | `unreadable_session` | warn | A cookie arrived but would not decrypt. Corruption, or a rotated secret. |
51
+ | `no_refresh_token` | warn | The session has no refresh token — usually `offline_access` was never granted. |
52
+
53
+ The level follows the **reason**, not the event. A single level for the whole
54
+ event would be wrong in both directions: an anonymous request hitting a protected
55
+ route is not a warning, and a session cookie that won't decrypt is not routine.
56
+ Getting this wrong is how a warn stream becomes unreadable and an operator
57
+ concludes there is "nothing in the logs".
58
+
59
+ ### `cause` — why a refresh failed
60
+
61
+ | `cause` | Level | Means | Session |
62
+ | --------------- | ----- | ------------------------------------------------------------- | ------- |
63
+ | `invalid_grant` | info | The provider says the refresh token is dead. | Over |
64
+ | `transport` | warn | The provider was never reached — DNS, TCP, TLS, timeout. | Probably fine |
65
+ | `idp_error` | error | The provider answered with an error or something unparseable. | Probably fine |
66
+
67
+ Only `invalid_grant` ends a session. The heartbeat endpoint answers `401` for it
68
+ and `503` for the other two, so a provider outage no longer logs out users whose
69
+ sessions were valid.
70
+
71
+ ## `sid` — the correlation id
72
+
73
+ Minted when the session is created, carried across refreshes, and attached to
74
+ every event above. It is **not** derived from the refresh token: a hash of that
75
+ would change every time the provider rotates it — on every refresh for a default
76
+ Keycloak — and following one session through its whole life is the point.
77
+ Rotation stays visible through `rotatedRefreshToken` instead.
78
+
79
+ > **Cardinality.** `sid` is high-cardinality by design. Keep it a log *field*.
80
+ > Never promote it to a Loki label, a Prometheus dimension or a Datadog tag — one
81
+ > series per session will melt the index. `event`, `reason`, `cause` and
82
+ > `trigger` are the low-cardinality fields; group on those.
83
+
84
+ ## Questions these answer
85
+
86
+ **Which path ended this session?**
87
+
88
+ ```logql
89
+ {app="web"} | json | sid = `a3f81c92`
90
+ ```
91
+
92
+ One query, whole lifetime: creation, every refresh, and whatever finally ended it.
93
+
94
+ **Was the refresh token really dead, or did we give up early?**
95
+
96
+ ```logql
97
+ sum by (cause) (
98
+ count_over_time({app="web"} | json | event = `session.refresh_failed` [1h])
99
+ )
100
+ ```
101
+
102
+ `invalid_grant` climbing means sessions genuinely expiring. `transport` or
103
+ `idp_error` climbing means an infrastructure problem wearing a logout costume.
104
+
105
+ **Does the provider rotate refresh tokens?**
106
+
107
+ ```logql
108
+ sum by (rotatedRefreshToken) (
109
+ count_over_time({app="web"} | json | event = `session.refreshed` [1h])
110
+ )
111
+ ```
112
+
113
+ If this is `true`, a lost cookie write bricks the session — worth knowing before
114
+ you debug one.
115
+
116
+ **Did the provider grant `offline_access`?**
117
+
118
+ ```logql
119
+ {app="web"} | json | event = `session.created` | hasRefreshToken = `false`
120
+ ```
121
+
122
+ Any hit here guarantees logouts at access-token TTL. Alert on it: it should be
123
+ zero.
124
+
125
+ **Are sessions about to outgrow the cookie?**
126
+
127
+ ```logql
128
+ {app="web"} | json | event =~ `session\.(created|refreshed)` | cookieBytes > 3500
129
+ ```
130
+
131
+ Browsers drop a cookie over ~4096 bytes silently, producing a login that appears
132
+ to work and then doesn't.
133
+
134
+ ## Suggested alerts
135
+
136
+ | Condition | Why |
137
+ | --------- | --- |
138
+ | any `session.created` with `hasRefreshToken=false` | Guaranteed logouts at access-token TTL. |
139
+ | `session.refresh_failed` with `cause=idp_error` above baseline | The provider is failing. |
140
+ | `session.rejected` with `reason=unreadable_session` above ~0 | Corruption, or a secret rotated without draining sessions. |
141
+ | `cookieBytes` p99 approaching 4096 | Sessions about to break silently. |
142
+
143
+ Rate-of-`invalid_grant` is deliberately *not* on this list — it rises whenever an
144
+ SSO lifetime ends, which is normal.
145
+
146
+ ## Browser events
147
+
148
+ `createHeartbeat` and `startSessionMonitor` write to the browser console, which
149
+ production never sees. Both take an `onEvent` callback carrying the same names
150
+ and the same `reason` vocabulary:
151
+
152
+ ```ts
153
+ useHeartbeat({
154
+ onEvent: (event) => navigator.sendBeacon("/api/telemetry", JSON.stringify(event)),
155
+ });
156
+ ```
157
+
158
+ The library ships no transport — where these go, and whether they go anywhere, is
159
+ yours to decide.
160
+
161
+ The shapes differ where the browser genuinely knows less. It never learns *why* a
162
+ refresh failed server-side, only the status it got back, so there is no `cause`
163
+ on the client — just `status` and `attempt`. The `reason` on a client
164
+ `session.rejected` is whatever the heartbeat endpoint put in its 401 body, and an
165
+ unrecognised value is dropped rather than passed through.
166
+
167
+ ## Consuming the vocabulary
168
+
169
+ `tryReadSession` and `tryRefreshSessionInStore` return the reason and cause
170
+ instead of a bare `null`, so your own proxy and status routes can log the same
171
+ words the library does:
172
+
173
+ ```ts
174
+ import { tryReadSession } from "@eventuras/fides-auth/server";
175
+ import { SESSION_EVENT, logSessionEvent } from "@eventuras/fides-auth/session-events";
176
+
177
+ const { session, reason } = await tryReadSession(cookies, secret);
178
+ if (!session) {
179
+ logSessionEvent(logger, {
180
+ event: SESSION_EVENT.REJECTED,
181
+ reason,
182
+ source: "proxy",
183
+ });
184
+ return unauthorized();
185
+ }
186
+ ```
187
+
188
+ `logSessionEvent` picks the level from the policy above, so every caller stays
189
+ consistent without repeating it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventuras/fides-auth",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Framework-agnostic OAuth/OIDC authentication library with PKCE, session management, and pluggable logging",
5
5
  "keywords": [
6
6
  "oauth",
@@ -59,6 +59,10 @@
59
59
  "types": "./dist/session-cookies.d.ts",
60
60
  "import": "./dist/session-cookies.js"
61
61
  },
62
+ "./session-events": {
63
+ "types": "./dist/session-events.d.ts",
64
+ "import": "./dist/session-events.js"
65
+ },
62
66
  "./oauth": {
63
67
  "types": "./dist/oauth.d.ts",
64
68
  "import": "./dist/oauth.js"
@@ -111,6 +115,7 @@
111
115
  "devDependencies": {
112
116
  "@types/node": "^22.20.1",
113
117
  "jsdom": "29.1.1",
118
+ "typescript": "^6.0.2",
114
119
  "vite": "^8.2.0",
115
120
  "vitest": "^4.1.10",
116
121
  "@eventuras/typescript-config": "1.0.0",
@@ -121,6 +126,7 @@
121
126
  },
122
127
  "scripts": {
123
128
  "build": "vite build",
129
+ "typecheck": "tsc --noEmit",
124
130
  "dev": "vite build --watch",
125
131
  "test": "vitest run",
126
132
  "test:watch": "vitest"