@eventuras/fides-auth 0.11.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.
- package/dist/heartbeat.d.ts +20 -2
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +37 -3
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/oauth-logging.d.ts +15 -0
- package/dist/oauth-logging.d.ts.map +1 -1
- package/dist/oauth-logging.js +63 -1
- package/dist/oauth-logging.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +2 -0
- package/dist/oauth.js.map +1 -1
- package/dist/server/heartbeat-handler.d.ts +10 -3
- package/dist/server/heartbeat-handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +184 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/oidc-callback.d.ts.map +1 -1
- package/dist/server/oidc-logout.d.ts.map +1 -1
- package/dist/server/session.d.ts +78 -7
- package/dist/server/session.d.ts.map +1 -1
- package/dist/session-cookies.d.ts +30 -4
- package/dist/session-cookies.d.ts.map +1 -1
- package/dist/session-cookies.js +39 -39
- package/dist/session-cookies.js.map +1 -1
- package/dist/session-events.d.ts +149 -0
- package/dist/session-events.d.ts.map +1 -0
- package/dist/session-events.js +96 -0
- package/dist/session-events.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/session-events.md +189 -0
- package/package.json +7 -1
|
@@ -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.
|
|
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"
|