@mentra/cloud-client 3.2.0-dev.136 → 3.2.0-dev.153
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/package.json +2 -2
- package/src/client.ts +72 -68
- package/src/config.ts +21 -21
- package/src/errors.ts +18 -0
- package/src/index.ts +7 -1
- package/src/modules/auth/auth.ts +308 -195
- package/src/modules/auth/token-store.ts +27 -25
package/src/modules/auth/auth.ts
CHANGED
|
@@ -25,18 +25,14 @@
|
|
|
25
25
|
* See docs/issues/004-cloud-client/spec.md ("cloud.auth"), design.md, and
|
|
26
26
|
* docs/issues/001-cloud-core/auth/spec.md (endpoints + token shapes).
|
|
27
27
|
*/
|
|
28
|
-
import type {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "../../
|
|
34
|
-
import
|
|
35
|
-
import
|
|
36
|
-
import type { HttpTransport } from "../../transports";
|
|
37
|
-
import { AuthExpiredError } from "../../errors";
|
|
38
|
-
import { decodeClaims } from "./jwt";
|
|
39
|
-
import { TokenStore } from "./token-store";
|
|
28
|
+
import type {AuthConfig, CoreAuthConfig, RuntimeAuthConfig, SubjectTokenType} from "../../config"
|
|
29
|
+
import type {HttpClient} from "../../http"
|
|
30
|
+
import type {Logger} from "../../logger"
|
|
31
|
+
import type {HttpTransport} from "../../transports"
|
|
32
|
+
import {AuthExpiredError, HttpError, SessionRevocationError} from "../../errors"
|
|
33
|
+
import {systemTimers, type CloudClientTimers} from "../../timers"
|
|
34
|
+
import {decodeClaims} from "./jwt"
|
|
35
|
+
import {TokenStore} from "./token-store"
|
|
40
36
|
|
|
41
37
|
/**
|
|
42
38
|
* The public auth surface a host uses, from spec.md.
|
|
@@ -51,85 +47,105 @@ export interface AuthModule {
|
|
|
51
47
|
// `{ forceRefresh: true }` to bypass the in-memory cache and refresh now, for
|
|
52
48
|
// the case where the cloud rejected a token the client still thinks is fresh
|
|
53
49
|
// (clock skew or a mid-session revoke surfaced as AUTH_EXPIRED).
|
|
54
|
-
getRuntimeToken(opts?: {
|
|
50
|
+
getRuntimeToken(opts?: {forceRefresh?: boolean}): Promise<string>
|
|
55
51
|
// current Core token, refreshing as needed (Core-backed mode only).
|
|
56
|
-
getCoreToken(opts?: {
|
|
52
|
+
getCoreToken(opts?: {forceRefresh?: boolean}): Promise<string>
|
|
57
53
|
// a miniapp-scoped token, cached per packageName and re-minted before expiry
|
|
58
54
|
getMiniappToken(
|
|
59
55
|
packageName: string,
|
|
60
|
-
opts?: {
|
|
61
|
-
): Promise<{
|
|
56
|
+
opts?: {minTtlMs?: number; devAttestation?: string},
|
|
57
|
+
): Promise<{token: string; expiresAt: number}>
|
|
62
58
|
// Core-owned user/oem identity, read from the Core access token.
|
|
63
59
|
// Runtime-only deployments do not expose this surface.
|
|
64
|
-
readonly identity: {
|
|
60
|
+
readonly identity: {mentraUserId: string; tenantId: string}
|
|
61
|
+
// clear access, refresh, runtime, and miniapp credentials for logout. Local
|
|
62
|
+
// credentials are always cleared; rejects with `SessionRevocationError` when
|
|
63
|
+
// Core did not confirm revocation of the server-side session.
|
|
64
|
+
clearSession(): Promise<void>
|
|
65
65
|
// refresh failed; the host must send the user back through login
|
|
66
|
-
onExpired(handler: () => void): () => void
|
|
66
|
+
onExpired(handler: () => void): () => void
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** RFC 8693 token-exchange grant type for the `/exchange` call. */
|
|
70
|
-
const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
70
|
+
const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Map a config `SubjectTokenType` to the RFC 8693 `subject_token_type` URN the
|
|
74
74
|
* cloud expects.
|
|
75
75
|
*
|
|
76
|
-
* All
|
|
77
|
-
* Supabase session) are presented as a JWT and verified by their own `iss` /
|
|
76
|
+
* All supported subject tokens (OIDC, an OEM-signed JWT, a Mentra core token,
|
|
77
|
+
* or a Supabase session) are presented as a JWT and verified by their own `iss` /
|
|
78
78
|
* verification path on the cloud, so they share the one JWT token-type URN. The
|
|
79
79
|
* mapping is kept explicit (rather than hard-coding one URN) so that if the
|
|
80
80
|
* cloud later wants distinct URNs per source, this is the single place to widen.
|
|
81
81
|
*/
|
|
82
82
|
const SUBJECT_TOKEN_TYPE_URN: Record<SubjectTokenType, string> = {
|
|
83
|
+
"oidc": "urn:ietf:params:oauth:token-type:jwt",
|
|
83
84
|
"oem-jwt": "urn:ietf:params:oauth:token-type:jwt",
|
|
84
85
|
"mentra-core": "urn:ietf:params:oauth:token-type:jwt",
|
|
85
|
-
supabase: "urn:ietf:params:oauth:token-type:jwt",
|
|
86
|
-
}
|
|
86
|
+
"supabase": "urn:ietf:params:oauth:token-type:jwt",
|
|
87
|
+
}
|
|
87
88
|
|
|
88
89
|
/** Seconds of headroom before `exp` at which we proactively refresh/re-mint. */
|
|
89
|
-
const EXPIRY_MARGIN_SECONDS = 60
|
|
90
|
+
const EXPIRY_MARGIN_SECONDS = 60
|
|
91
|
+
|
|
92
|
+
/** Total attempts at the idempotent `/revoke` during logout on network/5xx failures. */
|
|
93
|
+
const REVOKE_ATTEMPTS = 3
|
|
94
|
+
/** Base backoff between revoke attempts in milliseconds; doubles per attempt. */
|
|
95
|
+
const REVOKE_RETRY_BASE_MS = 250
|
|
90
96
|
|
|
91
97
|
/** The cloud's token endpoints, relative to the core base URL. */
|
|
92
|
-
const EXCHANGE_PATH = "/api/client/auth/exchange"
|
|
93
|
-
const REFRESH_PATH = "/api/client/auth/refresh"
|
|
94
|
-
const RUNTIME_TOKEN_PATH = "/api/client/auth/runtime-token"
|
|
95
|
-
const MINIAPP_TOKEN_PATH = "/api/client/auth/miniapp-token"
|
|
98
|
+
const EXCHANGE_PATH = "/api/client/auth/exchange"
|
|
99
|
+
const REFRESH_PATH = "/api/client/auth/refresh"
|
|
100
|
+
const RUNTIME_TOKEN_PATH = "/api/client/auth/runtime-token"
|
|
101
|
+
const MINIAPP_TOKEN_PATH = "/api/client/auth/miniapp-token"
|
|
102
|
+
const REVOKE_PATH = "/api/client/auth/revoke"
|
|
96
103
|
|
|
97
104
|
/** Single-flight keys. The miniapp key is suffixed per packageName below. */
|
|
98
|
-
const FLIGHT_ACCESS = "access-token"
|
|
99
|
-
const FLIGHT_RUNTIME = "runtime-token"
|
|
100
|
-
const MINIAPP_FLIGHT_PREFIX = "miniapp-token:"
|
|
105
|
+
const FLIGHT_ACCESS = "access-token"
|
|
106
|
+
const FLIGHT_RUNTIME = "runtime-token"
|
|
107
|
+
const MINIAPP_FLIGHT_PREFIX = "miniapp-token:"
|
|
101
108
|
|
|
102
109
|
/** The cloud's RFC-shaped token response from `/exchange` and `/refresh`. */
|
|
103
110
|
interface TokenResponse {
|
|
104
|
-
access_token: string
|
|
105
|
-
refresh_token: string
|
|
106
|
-
token_type: string
|
|
107
|
-
expires_in: number
|
|
111
|
+
access_token: string
|
|
112
|
+
refresh_token: string
|
|
113
|
+
token_type: string
|
|
114
|
+
expires_in: number
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
/** A cached miniapp token plus its absolute expiry (Unix seconds). */
|
|
111
118
|
interface MiniappTokenEntry {
|
|
112
|
-
token: string
|
|
113
|
-
expiresAt: number
|
|
119
|
+
token: string
|
|
120
|
+
expiresAt: number
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
interface RuntimeTokenResponse {
|
|
117
|
-
access_token: string
|
|
118
|
-
token_type: string
|
|
119
|
-
expires_in: number
|
|
124
|
+
access_token: string
|
|
125
|
+
token_type: string
|
|
126
|
+
expires_in: number
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
interface RuntimeTokenEntry {
|
|
123
|
-
token: string
|
|
124
|
-
expiresAt: number
|
|
130
|
+
token: string
|
|
131
|
+
expiresAt: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A revoke is worth retrying only when Core gave no definite answer: the
|
|
136
|
+
* transport failed (`status` 0) or the server errored (5xx). A 4xx means Core
|
|
137
|
+
* rejected the request and repeating it would not change the outcome.
|
|
138
|
+
*/
|
|
139
|
+
function isRetryableRevokeError(err: unknown): boolean {
|
|
140
|
+
return err instanceof HttpError && (err.status === 0 || err.status >= 500)
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
export class Auth implements AuthModule {
|
|
128
|
-
private readonly http?: HttpClient
|
|
129
|
-
private readonly store: TokenStore
|
|
130
|
-
private readonly coreConfig?: CoreAuthConfig
|
|
131
|
-
private readonly runtimeConfig: RuntimeAuthConfig
|
|
132
|
-
private readonly logger: Logger
|
|
144
|
+
private readonly http?: HttpClient
|
|
145
|
+
private readonly store: TokenStore
|
|
146
|
+
private readonly coreConfig?: CoreAuthConfig
|
|
147
|
+
private readonly runtimeConfig: RuntimeAuthConfig
|
|
148
|
+
private readonly logger: Logger
|
|
133
149
|
/**
|
|
134
150
|
* Core base URL for the form-encoded `/exchange` and `/refresh` calls.
|
|
135
151
|
*
|
|
@@ -140,38 +156,45 @@ export class Auth implements AuthModule {
|
|
|
140
156
|
* design: Core identity, miniapp token minting, and miniapp auto-auth are
|
|
141
157
|
* Core-backed features.
|
|
142
158
|
*/
|
|
143
|
-
private readonly baseUrl?: string
|
|
144
|
-
private readonly httpTransport: HttpTransport
|
|
159
|
+
private readonly baseUrl?: string
|
|
160
|
+
private readonly httpTransport: HttpTransport
|
|
161
|
+
private readonly timers: CloudClientTimers
|
|
145
162
|
|
|
146
163
|
/** Miniapp tokens cached per packageName until near expiry. */
|
|
147
|
-
private readonly miniappCache = new Map<string, MiniappTokenEntry>()
|
|
164
|
+
private readonly miniappCache = new Map<string, MiniappTokenEntry>()
|
|
148
165
|
/** Core-brokered runtime token cache. */
|
|
149
|
-
private runtimeToken: RuntimeTokenEntry | null = null
|
|
166
|
+
private runtimeToken: RuntimeTokenEntry | null = null
|
|
150
167
|
|
|
151
168
|
/** Registered `onExpired` handlers. */
|
|
152
|
-
private readonly expiredHandlers = new Set<() => void>()
|
|
169
|
+
private readonly expiredHandlers = new Set<() => void>()
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
172
|
* Latches once `onExpired` has fired, so a dead refresh token notifies the
|
|
156
173
|
* host exactly once instead of on every subsequent call.
|
|
157
174
|
*/
|
|
158
|
-
private expiredFired = false
|
|
175
|
+
private expiredFired = false
|
|
176
|
+
/** Incremented on logout so late async results cannot repopulate credentials. */
|
|
177
|
+
private sessionGeneration = 0
|
|
178
|
+
/** A logged-out client stays inert until the host constructs a fresh client. */
|
|
179
|
+
private sessionCleared = false
|
|
159
180
|
|
|
160
181
|
constructor(deps: {
|
|
161
|
-
http?: HttpClient
|
|
162
|
-
store: TokenStore
|
|
163
|
-
config: AuthConfig
|
|
164
|
-
logger: Logger
|
|
165
|
-
baseUrl?: string
|
|
166
|
-
fetch?: HttpTransport
|
|
182
|
+
http?: HttpClient
|
|
183
|
+
store: TokenStore
|
|
184
|
+
config: AuthConfig
|
|
185
|
+
logger: Logger
|
|
186
|
+
baseUrl?: string
|
|
187
|
+
fetch?: HttpTransport
|
|
188
|
+
timers?: CloudClientTimers
|
|
167
189
|
}) {
|
|
168
|
-
this.http = deps.http
|
|
169
|
-
this.store = deps.store
|
|
170
|
-
this.coreConfig = deps.config.core
|
|
171
|
-
this.runtimeConfig = deps.config.runtime
|
|
172
|
-
this.logger = deps.logger
|
|
173
|
-
this.baseUrl = deps.baseUrl
|
|
174
|
-
this.httpTransport = deps.fetch ?? globalThis.fetch
|
|
190
|
+
this.http = deps.http
|
|
191
|
+
this.store = deps.store
|
|
192
|
+
this.coreConfig = deps.config.core
|
|
193
|
+
this.runtimeConfig = deps.config.runtime
|
|
194
|
+
this.logger = deps.logger
|
|
195
|
+
this.baseUrl = deps.baseUrl
|
|
196
|
+
this.httpTransport = deps.fetch ?? globalThis.fetch
|
|
197
|
+
this.timers = deps.timers ?? systemTimers
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/**
|
|
@@ -181,39 +204,46 @@ export class Auth implements AuthModule {
|
|
|
181
204
|
* margin is returned with no network call. Otherwise we obtain one through a
|
|
182
205
|
* single-flight so concurrent callers share the same request.
|
|
183
206
|
*/
|
|
184
|
-
async getRuntimeToken(opts?: {
|
|
207
|
+
async getRuntimeToken(opts?: {forceRefresh?: boolean}): Promise<string> {
|
|
208
|
+
this.assertSessionActive()
|
|
185
209
|
if ("getToken" in this.runtimeConfig) {
|
|
186
|
-
|
|
210
|
+
const generation = this.sessionGeneration
|
|
211
|
+
const token = await this.runtimeConfig.getToken(opts)
|
|
212
|
+
this.assertSessionActive(generation)
|
|
213
|
+
return token
|
|
187
214
|
}
|
|
188
215
|
|
|
189
216
|
if (opts?.forceRefresh) {
|
|
190
|
-
this.runtimeToken = null
|
|
217
|
+
this.runtimeToken = null
|
|
191
218
|
} else if (this.runtimeToken && !this.isExpiring(this.runtimeToken.expiresAt)) {
|
|
192
|
-
return this.runtimeToken.token
|
|
219
|
+
return this.runtimeToken.token
|
|
193
220
|
}
|
|
194
221
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
222
|
+
const generation = this.sessionGeneration
|
|
223
|
+
return this.store.singleFlight(`${FLIGHT_RUNTIME}:${generation}`, () =>
|
|
224
|
+
this.obtainCoreBrokeredRuntimeToken(generation),
|
|
225
|
+
)
|
|
198
226
|
}
|
|
199
227
|
|
|
200
|
-
async getCoreToken(opts?: {
|
|
201
|
-
this.
|
|
228
|
+
async getCoreToken(opts?: {forceRefresh?: boolean}): Promise<string> {
|
|
229
|
+
this.assertSessionActive()
|
|
230
|
+
this.requireCoreConfig()
|
|
202
231
|
// A forced refresh drops the cached access token first, so the cache check
|
|
203
232
|
// below misses and we go straight to the single-flight refresh. The
|
|
204
233
|
// single-flight still de-dupes, so a burst of forced refreshes (one per
|
|
205
234
|
// reconnect attempt) collapses to one request.
|
|
206
235
|
if (opts?.forceRefresh) {
|
|
207
|
-
this.store.invalidateAccess()
|
|
236
|
+
this.store.invalidateAccess()
|
|
208
237
|
} else {
|
|
209
|
-
const cached = this.store.current()
|
|
238
|
+
const cached = this.store.current()
|
|
210
239
|
if (cached && !this.isExpiring(cached.exp)) {
|
|
211
|
-
return cached.accessToken
|
|
240
|
+
return cached.accessToken
|
|
212
241
|
}
|
|
213
242
|
}
|
|
214
243
|
|
|
215
244
|
// De-dupe: if a refresh/exchange is already running, await that one result.
|
|
216
|
-
|
|
245
|
+
const generation = this.sessionGeneration
|
|
246
|
+
return this.store.singleFlight(`${FLIGHT_ACCESS}:${generation}`, () => this.obtainAccessToken(generation))
|
|
217
247
|
}
|
|
218
248
|
|
|
219
249
|
/**
|
|
@@ -227,38 +257,41 @@ export class Auth implements AuthModule {
|
|
|
227
257
|
*/
|
|
228
258
|
async getMiniappToken(
|
|
229
259
|
packageName: string,
|
|
230
|
-
opts?: {
|
|
231
|
-
): Promise<{
|
|
232
|
-
|
|
233
|
-
const
|
|
260
|
+
opts?: {minTtlMs?: number; devAttestation?: string},
|
|
261
|
+
): Promise<{token: string; expiresAt: number}> {
|
|
262
|
+
this.assertSessionActive()
|
|
263
|
+
const generation = this.sessionGeneration
|
|
264
|
+
const marginSeconds = this.tokenMarginSeconds(opts?.minTtlMs)
|
|
265
|
+
const cached = this.miniappCache.get(packageName)
|
|
234
266
|
if (cached && !this.isExpiring(cached.expiresAt, marginSeconds)) {
|
|
235
|
-
return {
|
|
267
|
+
return {token: cached.token, expiresAt: cached.expiresAt}
|
|
236
268
|
}
|
|
237
269
|
|
|
238
|
-
return this.store.singleFlight(MINIAPP_FLIGHT_PREFIX
|
|
270
|
+
return this.store.singleFlight(`${MINIAPP_FLIGHT_PREFIX}${packageName}:${generation}`, async () => {
|
|
239
271
|
// Re-check the cache inside the flight: a concurrent mint that resolved
|
|
240
272
|
// while we were queued may have already filled it.
|
|
241
|
-
const fresh = this.miniappCache.get(packageName)
|
|
273
|
+
const fresh = this.miniappCache.get(packageName)
|
|
242
274
|
if (fresh && !this.isExpiring(fresh.expiresAt, marginSeconds)) {
|
|
243
|
-
return {
|
|
275
|
+
return {token: fresh.token, expiresAt: fresh.expiresAt}
|
|
244
276
|
}
|
|
245
277
|
|
|
246
|
-
const accessToken = await this.getCoreToken()
|
|
247
|
-
const http = this.requireCoreHttp()
|
|
278
|
+
const accessToken = await this.getCoreToken()
|
|
279
|
+
const http = this.requireCoreHttp()
|
|
248
280
|
const res = await http.post<MiniappTokenEntry>(
|
|
249
281
|
MINIAPP_TOKEN_PATH,
|
|
250
282
|
{
|
|
251
283
|
packageName,
|
|
252
|
-
...(opts?.devAttestation ? {
|
|
284
|
+
...(opts?.devAttestation ? {devAttestation: opts.devAttestation} : {}),
|
|
253
285
|
},
|
|
254
|
-
{
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
this.
|
|
260
|
-
|
|
261
|
-
|
|
286
|
+
{bearer: accessToken},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
this.assertSessionActive(generation)
|
|
290
|
+
const entry: MiniappTokenEntry = {token: res.token, expiresAt: res.expiresAt}
|
|
291
|
+
this.miniappCache.set(packageName, entry)
|
|
292
|
+
this.logger.debug("minted miniapp token", {packageName})
|
|
293
|
+
return {token: entry.token, expiresAt: entry.expiresAt}
|
|
294
|
+
})
|
|
262
295
|
}
|
|
263
296
|
|
|
264
297
|
/**
|
|
@@ -273,14 +306,74 @@ export class Auth implements AuthModule {
|
|
|
273
306
|
* every call, so the client need not). Throws if no Core access token has been
|
|
274
307
|
* obtained yet, since Core identity is meaningless before the first exchange.
|
|
275
308
|
*/
|
|
276
|
-
get identity(): {
|
|
277
|
-
this.requireCoreConfig()
|
|
278
|
-
const current = this.store.current()
|
|
309
|
+
get identity(): {mentraUserId: string; tenantId: string} {
|
|
310
|
+
this.requireCoreConfig()
|
|
311
|
+
const current = this.store.current()
|
|
279
312
|
if (!current) {
|
|
280
|
-
throw new AuthExpiredError("Core identity is unavailable before first Core sign-in")
|
|
313
|
+
throw new AuthExpiredError("Core identity is unavailable before first Core sign-in")
|
|
314
|
+
}
|
|
315
|
+
const claims = decodeClaims(current.accessToken)
|
|
316
|
+
return {mentraUserId: claims.sub, tenantId: claims.tenant_id}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async clearSession(): Promise<void> {
|
|
320
|
+
this.sessionGeneration += 1
|
|
321
|
+
this.sessionCleared = true
|
|
322
|
+
let revokeError: unknown
|
|
323
|
+
try {
|
|
324
|
+
await this.revokeCoreSession()
|
|
325
|
+
} catch (err) {
|
|
326
|
+
revokeError = err
|
|
327
|
+
this.logger.warn("Core session revocation failed during local logout", {
|
|
328
|
+
error: (err as Error)?.message,
|
|
329
|
+
})
|
|
330
|
+
} finally {
|
|
331
|
+
await this.store.clear()
|
|
332
|
+
this.runtimeToken = null
|
|
333
|
+
this.miniappCache.clear()
|
|
334
|
+
this.expiredFired = false
|
|
335
|
+
}
|
|
336
|
+
if (revokeError) {
|
|
337
|
+
throw new SessionRevocationError("Core session revocation failed; local credentials were cleared", {
|
|
338
|
+
cause: revokeError,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Revoke the Core session server-side before local credentials are dropped.
|
|
345
|
+
*
|
|
346
|
+
* Core verifies the presented access token with no clock tolerance, so a
|
|
347
|
+
* cached token inside the usual expiry margin is refreshed first rather than
|
|
348
|
+
* risking a rejected revoke. `/revoke` is idempotent, so a network error or a
|
|
349
|
+
* 5xx is retried a bounded number of times with backoff; a definite 4xx is
|
|
350
|
+
* not. Any failure propagates so the caller can tell revocation did not
|
|
351
|
+
* complete.
|
|
352
|
+
*/
|
|
353
|
+
private async revokeCoreSession(): Promise<void> {
|
|
354
|
+
if (!this.coreConfig || !this.http) return
|
|
355
|
+
const current = this.store.current()
|
|
356
|
+
const refreshToken = await this.store.refreshToken()
|
|
357
|
+
let accessToken: string | null = null
|
|
358
|
+
if (current && !this.isExpiring(current.exp)) {
|
|
359
|
+
accessToken = current.accessToken
|
|
360
|
+
} else if (refreshToken) {
|
|
361
|
+
const body = new URLSearchParams({grant_type: "refresh_token", refresh_token: refreshToken})
|
|
362
|
+
const tokens = await this.postForm(REFRESH_PATH, body, "logout refresh")
|
|
363
|
+
accessToken = tokens.access_token
|
|
364
|
+
}
|
|
365
|
+
if (!accessToken) return
|
|
366
|
+
|
|
367
|
+
for (let attempt = 0; ; attempt++) {
|
|
368
|
+
try {
|
|
369
|
+
await this.http.post(REVOKE_PATH, {}, {bearer: accessToken})
|
|
370
|
+
return
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (attempt + 1 >= REVOKE_ATTEMPTS || !isRetryableRevokeError(err)) throw err
|
|
373
|
+
this.logger.warn("Core session revocation failed; retrying", {attempt: attempt + 1})
|
|
374
|
+
await new Promise<void>((resolve) => this.timers.setTimeout(resolve, REVOKE_RETRY_BASE_MS * 2 ** attempt))
|
|
375
|
+
}
|
|
281
376
|
}
|
|
282
|
-
const claims = decodeClaims(current.accessToken);
|
|
283
|
-
return { mentraUserId: claims.sub, tenantId: claims.tenant_id };
|
|
284
377
|
}
|
|
285
378
|
|
|
286
379
|
/**
|
|
@@ -291,10 +384,10 @@ export class Auth implements AuthModule {
|
|
|
291
384
|
* `fireExpired`).
|
|
292
385
|
*/
|
|
293
386
|
onExpired(handler: () => void): () => void {
|
|
294
|
-
this.expiredHandlers.add(handler)
|
|
387
|
+
this.expiredHandlers.add(handler)
|
|
295
388
|
return () => {
|
|
296
|
-
this.expiredHandlers.delete(handler)
|
|
297
|
-
}
|
|
389
|
+
this.expiredHandlers.delete(handler)
|
|
390
|
+
}
|
|
298
391
|
}
|
|
299
392
|
|
|
300
393
|
// === internals ===
|
|
@@ -307,15 +400,15 @@ export class Auth implements AuthModule {
|
|
|
307
400
|
* in seconds and subtract the margin.
|
|
308
401
|
*/
|
|
309
402
|
private isExpiring(expSeconds: number, marginSeconds = EXPIRY_MARGIN_SECONDS): boolean {
|
|
310
|
-
const nowSeconds = Math.floor(Date.now() / 1000)
|
|
311
|
-
return expSeconds - marginSeconds <= nowSeconds
|
|
403
|
+
const nowSeconds = Math.floor(Date.now() / 1000)
|
|
404
|
+
return expSeconds - marginSeconds <= nowSeconds
|
|
312
405
|
}
|
|
313
406
|
|
|
314
407
|
private tokenMarginSeconds(minTtlMs?: number): number {
|
|
315
408
|
if (!Number.isFinite(minTtlMs) || !minTtlMs || minTtlMs <= 0) {
|
|
316
|
-
return EXPIRY_MARGIN_SECONDS
|
|
409
|
+
return EXPIRY_MARGIN_SECONDS
|
|
317
410
|
}
|
|
318
|
-
return Math.max(EXPIRY_MARGIN_SECONDS, Math.ceil(minTtlMs / 1000))
|
|
411
|
+
return Math.max(EXPIRY_MARGIN_SECONDS, Math.ceil(minTtlMs / 1000))
|
|
319
412
|
}
|
|
320
413
|
|
|
321
414
|
/**
|
|
@@ -323,24 +416,24 @@ export class Auth implements AuthModule {
|
|
|
323
416
|
* do the first-use exchange. Runs inside the single-flight from
|
|
324
417
|
* `getCoreToken`, so only one of these is ever in flight.
|
|
325
418
|
*/
|
|
326
|
-
private async obtainAccessToken(): Promise<string> {
|
|
327
|
-
const refreshToken = await this.store.refreshToken()
|
|
419
|
+
private async obtainAccessToken(generation: number): Promise<string> {
|
|
420
|
+
const refreshToken = await this.store.refreshToken()
|
|
328
421
|
if (refreshToken) {
|
|
329
422
|
try {
|
|
330
|
-
return await this.refresh(refreshToken, {
|
|
423
|
+
return await this.refresh(refreshToken, {deferExpired: this.canExchangeFreshSubject(), generation})
|
|
331
424
|
} catch (err) {
|
|
332
425
|
if (this.canExchangeFreshSubject()) {
|
|
333
|
-
this.logger.info("refresh failed; exchanging fresh subject token")
|
|
426
|
+
this.logger.info("refresh failed; exchanging fresh subject token")
|
|
334
427
|
try {
|
|
335
|
-
return await this.exchange()
|
|
428
|
+
return await this.exchange(generation)
|
|
336
429
|
} catch {
|
|
337
|
-
this.fireExpired()
|
|
430
|
+
this.fireExpired()
|
|
338
431
|
}
|
|
339
432
|
}
|
|
340
|
-
throw err
|
|
433
|
+
throw err
|
|
341
434
|
}
|
|
342
435
|
}
|
|
343
|
-
return this.exchange()
|
|
436
|
+
return this.exchange(generation)
|
|
344
437
|
}
|
|
345
438
|
|
|
346
439
|
/**
|
|
@@ -353,36 +446,42 @@ export class Auth implements AuthModule {
|
|
|
353
446
|
* no subject token at all: we persist its refresh token and refresh, since we
|
|
354
447
|
* only reach `exchange` when no refresh token is stored.
|
|
355
448
|
*/
|
|
356
|
-
private async exchange(): Promise<string> {
|
|
357
|
-
const config = this.requireCoreConfig()
|
|
449
|
+
private async exchange(generation = this.sessionGeneration): Promise<string> {
|
|
450
|
+
const config = this.requireCoreConfig()
|
|
358
451
|
// Pre-exchanged credentials: seed the store and refresh, no /exchange call.
|
|
359
452
|
if ("refreshToken" in config) {
|
|
360
|
-
await this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
453
|
+
await this.saveTokenPair(
|
|
454
|
+
{
|
|
455
|
+
accessToken: config.accessToken,
|
|
456
|
+
refreshToken: config.refreshToken,
|
|
457
|
+
},
|
|
458
|
+
generation,
|
|
459
|
+
)
|
|
364
460
|
// The seeded access token may already be near expiry, so refresh through
|
|
365
461
|
// the normal path to guarantee a fresh one.
|
|
366
|
-
return this.refresh(config.refreshToken)
|
|
462
|
+
return this.refresh(config.refreshToken, {generation})
|
|
367
463
|
}
|
|
368
464
|
|
|
369
|
-
const subject = await this.resolveSubjectToken(config)
|
|
465
|
+
const subject = await this.resolveSubjectToken(config)
|
|
370
466
|
const body = new URLSearchParams({
|
|
371
467
|
grant_type: TOKEN_EXCHANGE_GRANT,
|
|
372
468
|
subject_token: subject.token,
|
|
373
469
|
subject_token_type: SUBJECT_TOKEN_TYPE_URN[subject.type],
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange")
|
|
377
|
-
await this.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange")
|
|
473
|
+
await this.saveTokenPair(
|
|
474
|
+
{
|
|
475
|
+
accessToken: tokens.access_token,
|
|
476
|
+
refreshToken: tokens.refresh_token,
|
|
477
|
+
},
|
|
478
|
+
generation,
|
|
479
|
+
)
|
|
381
480
|
// A successful exchange means credentials are live again; clear the latch so
|
|
382
481
|
// a future failure can notify the host once more.
|
|
383
|
-
this.expiredFired = false
|
|
384
|
-
this.logger.info("exchanged subject token for access token")
|
|
385
|
-
return tokens.access_token
|
|
482
|
+
this.expiredFired = false
|
|
483
|
+
this.logger.info("exchanged subject token for access token")
|
|
484
|
+
return tokens.access_token
|
|
386
485
|
}
|
|
387
486
|
|
|
388
487
|
/**
|
|
@@ -395,36 +494,39 @@ export class Auth implements AuthModule {
|
|
|
395
494
|
* `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
|
|
396
495
|
* will not heal on its own, and retrying would loop.
|
|
397
496
|
*/
|
|
398
|
-
private async refresh(refreshToken: string, opts?: {
|
|
497
|
+
private async refresh(refreshToken: string, opts?: {deferExpired?: boolean; generation?: number}): Promise<string> {
|
|
399
498
|
const body = new URLSearchParams({
|
|
400
499
|
grant_type: "refresh_token",
|
|
401
500
|
refresh_token: refreshToken,
|
|
402
|
-
})
|
|
501
|
+
})
|
|
403
502
|
|
|
404
|
-
let tokens: TokenResponse
|
|
503
|
+
let tokens: TokenResponse
|
|
405
504
|
try {
|
|
406
|
-
tokens = await this.postForm(REFRESH_PATH, body, "refresh")
|
|
505
|
+
tokens = await this.postForm(REFRESH_PATH, body, "refresh")
|
|
407
506
|
} catch {
|
|
408
507
|
// The refresh token is unusable: drop it so we do not keep presenting a
|
|
409
508
|
// known-bad token.
|
|
410
|
-
await this.store.clear()
|
|
509
|
+
await this.store.clear()
|
|
411
510
|
if (!opts?.deferExpired) {
|
|
412
|
-
this.fireExpired()
|
|
511
|
+
this.fireExpired()
|
|
413
512
|
}
|
|
414
|
-
throw new AuthExpiredError("token refresh failed; re-auth required")
|
|
513
|
+
throw new AuthExpiredError("token refresh failed; re-auth required")
|
|
415
514
|
}
|
|
416
515
|
|
|
417
|
-
await this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
516
|
+
await this.saveTokenPair(
|
|
517
|
+
{
|
|
518
|
+
accessToken: tokens.access_token,
|
|
519
|
+
refreshToken: tokens.refresh_token,
|
|
520
|
+
},
|
|
521
|
+
opts?.generation ?? this.sessionGeneration,
|
|
522
|
+
)
|
|
523
|
+
this.expiredFired = false
|
|
524
|
+
this.logger.debug("refreshed access token")
|
|
525
|
+
return tokens.access_token
|
|
424
526
|
}
|
|
425
527
|
|
|
426
528
|
private canExchangeFreshSubject(): boolean {
|
|
427
|
-
return !!this.coreConfig && "getSubjectToken" in this.coreConfig
|
|
529
|
+
return !!this.coreConfig && "getSubjectToken" in this.coreConfig
|
|
428
530
|
}
|
|
429
531
|
|
|
430
532
|
/**
|
|
@@ -434,52 +536,61 @@ export class Auth implements AuthModule {
|
|
|
434
536
|
* Only reached for the two non-pre-exchanged config shapes; the caller handles
|
|
435
537
|
* the pre-exchanged shape before us, so the final throw is just exhaustiveness.
|
|
436
538
|
*/
|
|
437
|
-
private async resolveSubjectToken(
|
|
438
|
-
config: CoreAuthConfig,
|
|
439
|
-
): Promise<{ token: string; type: SubjectTokenType }> {
|
|
539
|
+
private async resolveSubjectToken(config: CoreAuthConfig): Promise<{token: string; type: SubjectTokenType}> {
|
|
440
540
|
if ("subjectToken" in config) {
|
|
441
|
-
return {
|
|
541
|
+
return {token: config.subjectToken, type: config.subjectTokenType}
|
|
442
542
|
}
|
|
443
543
|
if ("getSubjectToken" in config) {
|
|
444
|
-
return config.getSubjectToken()
|
|
544
|
+
return config.getSubjectToken()
|
|
445
545
|
}
|
|
446
|
-
throw new AuthExpiredError("no subject token available to exchange")
|
|
546
|
+
throw new AuthExpiredError("no subject token available to exchange")
|
|
447
547
|
}
|
|
448
548
|
|
|
449
549
|
private requireCoreConfig(): CoreAuthConfig {
|
|
450
550
|
if (!this.coreConfig) {
|
|
451
|
-
throw new AuthExpiredError("core auth is not configured; this API is unavailable in runtime-only mode")
|
|
551
|
+
throw new AuthExpiredError("core auth is not configured; this API is unavailable in runtime-only mode")
|
|
452
552
|
}
|
|
453
|
-
return this.coreConfig
|
|
553
|
+
return this.coreConfig
|
|
454
554
|
}
|
|
455
555
|
|
|
456
556
|
private requireCoreHttp(): HttpClient {
|
|
457
557
|
if (!this.http) {
|
|
458
|
-
throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
|
|
558
|
+
throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
|
|
459
559
|
}
|
|
460
|
-
return this.http
|
|
560
|
+
return this.http
|
|
461
561
|
}
|
|
462
562
|
|
|
463
|
-
private async obtainCoreBrokeredRuntimeToken(): Promise<string> {
|
|
464
|
-
const fresh = this.runtimeToken
|
|
563
|
+
private async obtainCoreBrokeredRuntimeToken(generation: number): Promise<string> {
|
|
564
|
+
const fresh = this.runtimeToken
|
|
465
565
|
if (fresh && !this.isExpiring(fresh.expiresAt)) {
|
|
466
|
-
return fresh.token
|
|
566
|
+
return fresh.token
|
|
467
567
|
}
|
|
468
568
|
|
|
469
|
-
const coreToken = await this.getCoreToken()
|
|
470
|
-
const http = this.requireCoreHttp()
|
|
471
|
-
const res = await http.post<RuntimeTokenResponse>(
|
|
472
|
-
RUNTIME_TOKEN_PATH,
|
|
473
|
-
{},
|
|
474
|
-
{ bearer: coreToken },
|
|
475
|
-
);
|
|
569
|
+
const coreToken = await this.getCoreToken()
|
|
570
|
+
const http = this.requireCoreHttp()
|
|
571
|
+
const res = await http.post<RuntimeTokenResponse>(RUNTIME_TOKEN_PATH, {}, {bearer: coreToken})
|
|
476
572
|
if (res.token_type !== "Bearer" || !res.access_token) {
|
|
477
|
-
throw new AuthExpiredError("core returned an invalid runtime token response")
|
|
573
|
+
throw new AuthExpiredError("core returned an invalid runtime token response")
|
|
478
574
|
}
|
|
479
575
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
576
|
+
this.assertSessionActive(generation)
|
|
577
|
+
const expiresAt = Math.floor(Date.now() / 1000) + res.expires_in
|
|
578
|
+
this.runtimeToken = {token: res.access_token, expiresAt}
|
|
579
|
+
return res.access_token
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Persist a token pair without allowing a concurrent logout to restore it. */
|
|
583
|
+
private async saveTokenPair(tokens: {accessToken: string; refreshToken: string}, generation: number): Promise<void> {
|
|
584
|
+
this.assertSessionActive(generation)
|
|
585
|
+
await this.store.save(tokens)
|
|
586
|
+
try {
|
|
587
|
+
this.assertSessionActive(generation)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
// A storage adapter may have completed its write after logout cleared the
|
|
590
|
+
// key. Clear once more so the late write cannot resurrect the session.
|
|
591
|
+
await this.store.clear()
|
|
592
|
+
throw error
|
|
593
|
+
}
|
|
483
594
|
}
|
|
484
595
|
|
|
485
596
|
/**
|
|
@@ -491,27 +602,23 @@ export class Auth implements AuthModule {
|
|
|
491
602
|
* subject/refresh token in the body, not as a Bearer header. We never log the
|
|
492
603
|
* body: it carries a token.
|
|
493
604
|
*/
|
|
494
|
-
private async postForm(
|
|
495
|
-
path
|
|
496
|
-
body: URLSearchParams,
|
|
497
|
-
label: string,
|
|
498
|
-
): Promise<TokenResponse> {
|
|
499
|
-
const url = this.joinUrl(path);
|
|
605
|
+
private async postForm(path: string, body: URLSearchParams, label: string): Promise<TokenResponse> {
|
|
606
|
+
const url = this.joinUrl(path)
|
|
500
607
|
const res = await this.httpTransport(url, {
|
|
501
608
|
method: "POST",
|
|
502
|
-
headers: {
|
|
609
|
+
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
|
503
610
|
body: body.toString(),
|
|
504
|
-
})
|
|
611
|
+
})
|
|
505
612
|
|
|
506
613
|
if (!res.ok) {
|
|
507
614
|
// The body may carry an RFC `{ error, error_description }`, but we keep the
|
|
508
615
|
// thrown detail to the status + label so no token field can leak into a
|
|
509
616
|
// message a host might surface. The caller maps this to re-auth.
|
|
510
|
-
this.logger.warn("auth token request failed", {
|
|
511
|
-
throw new AuthExpiredError(`${label} request failed with status ${res.status}`)
|
|
617
|
+
this.logger.warn("auth token request failed", {label, status: res.status})
|
|
618
|
+
throw new AuthExpiredError(`${label} request failed with status ${res.status}`)
|
|
512
619
|
}
|
|
513
620
|
|
|
514
|
-
return (await res.json()) as TokenResponse
|
|
621
|
+
return (await res.json()) as TokenResponse
|
|
515
622
|
}
|
|
516
623
|
|
|
517
624
|
/**
|
|
@@ -522,11 +629,11 @@ export class Auth implements AuthModule {
|
|
|
522
629
|
*/
|
|
523
630
|
private joinUrl(path: string): string {
|
|
524
631
|
if (!this.baseUrl) {
|
|
525
|
-
throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
|
|
632
|
+
throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
|
|
526
633
|
}
|
|
527
|
-
const base = this.baseUrl.replace(/\/+$/, "")
|
|
528
|
-
const suffix = path.replace(/^\/+/, "")
|
|
529
|
-
return `${base}/${suffix}
|
|
634
|
+
const base = this.baseUrl.replace(/\/+$/, "")
|
|
635
|
+
const suffix = path.replace(/^\/+/, "")
|
|
636
|
+
return `${base}/${suffix}`
|
|
530
637
|
}
|
|
531
638
|
|
|
532
639
|
/**
|
|
@@ -537,17 +644,23 @@ export class Auth implements AuthModule {
|
|
|
537
644
|
* exchange/refresh so a later failure can notify again.
|
|
538
645
|
*/
|
|
539
646
|
private fireExpired(): void {
|
|
540
|
-
if (this.expiredFired) return
|
|
541
|
-
this.expiredFired = true
|
|
647
|
+
if (this.expiredFired) return
|
|
648
|
+
this.expiredFired = true
|
|
542
649
|
for (const handler of this.expiredHandlers) {
|
|
543
650
|
try {
|
|
544
|
-
handler()
|
|
651
|
+
handler()
|
|
545
652
|
} catch (err) {
|
|
546
653
|
// A host handler throwing must not stop the others from running.
|
|
547
654
|
this.logger.error("onExpired handler threw", {
|
|
548
655
|
error: err instanceof Error ? err.message : String(err),
|
|
549
|
-
})
|
|
656
|
+
})
|
|
550
657
|
}
|
|
551
658
|
}
|
|
552
659
|
}
|
|
660
|
+
|
|
661
|
+
private assertSessionActive(generation = this.sessionGeneration): void {
|
|
662
|
+
if (this.sessionCleared || generation !== this.sessionGeneration) {
|
|
663
|
+
throw new AuthExpiredError("auth session was cleared")
|
|
664
|
+
}
|
|
665
|
+
}
|
|
553
666
|
}
|