@relyper/sp-auth 0.1.0 → 0.4.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.
Files changed (44) hide show
  1. package/README.md +216 -69
  2. package/dist/client.d.ts +33 -1
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +57 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/coins.d.ts +139 -0
  7. package/dist/coins.d.ts.map +1 -0
  8. package/dist/coins.js +210 -0
  9. package/dist/coins.js.map +1 -0
  10. package/dist/oidc/claims.d.ts +4 -0
  11. package/dist/oidc/claims.d.ts.map +1 -0
  12. package/dist/oidc/claims.js +50 -0
  13. package/dist/oidc/claims.js.map +1 -0
  14. package/dist/oidc/client.d.ts +44 -0
  15. package/dist/oidc/client.d.ts.map +1 -0
  16. package/dist/oidc/client.js +340 -0
  17. package/dist/oidc/client.js.map +1 -0
  18. package/dist/oidc/discovery.d.ts +15 -0
  19. package/dist/oidc/discovery.d.ts.map +1 -0
  20. package/dist/oidc/discovery.js +90 -0
  21. package/dist/oidc/discovery.js.map +1 -0
  22. package/dist/oidc/pkce.d.ts +6 -0
  23. package/dist/oidc/pkce.d.ts.map +1 -0
  24. package/dist/oidc/pkce.js +26 -0
  25. package/dist/oidc/pkce.js.map +1 -0
  26. package/dist/oidc/session.d.ts +47 -0
  27. package/dist/oidc/session.d.ts.map +1 -0
  28. package/dist/oidc/session.js +119 -0
  29. package/dist/oidc/session.js.map +1 -0
  30. package/dist/oidc/types.d.ts +130 -0
  31. package/dist/oidc/types.d.ts.map +1 -0
  32. package/dist/oidc/types.js +17 -0
  33. package/dist/oidc/types.js.map +1 -0
  34. package/dist/oidc-fastify.d.ts +123 -0
  35. package/dist/oidc-fastify.d.ts.map +1 -0
  36. package/dist/oidc-fastify.js +264 -0
  37. package/dist/oidc-fastify.js.map +1 -0
  38. package/dist/oidc.d.ts +15 -0
  39. package/dist/oidc.d.ts.map +1 -0
  40. package/dist/oidc.js +14 -0
  41. package/dist/oidc.js.map +1 -0
  42. package/dist/types.d.ts +13 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +25 -5
package/README.md CHANGED
@@ -1,12 +1,8 @@
1
1
  # @relyper/sp-auth
2
2
 
3
- Service-provider side of Relyper identity: turn gateway headers into a typed
4
- principal, gate access by role, and keep the identity layer out of your
5
- application code.
6
-
7
- Built for services that sit behind the Relyper auth gateway. The package covers
8
- the parts every service provider repeats — header parsing, role gating, the
9
- `/me` contract, dev login — and leaves persistence to you.
3
+ Relyper identity for a service provider. Register your app at the Relyper IdP,
4
+ authenticate it with its client secret, and get a typed principal with role
5
+ gating — without writing an OIDC client yourself.
10
6
 
11
7
  ```bash
12
8
  npm install @relyper/sp-auth
@@ -14,18 +10,49 @@ npm install @relyper/sp-auth
14
10
 
15
11
  Requires Node 20+. ESM only.
16
12
 
17
- ## Quick start (Fastify)
13
+ Two ways to establish who the user is, and they are not alternatives of equal
14
+ standing:
15
+
16
+ - **[OIDC login](#oidc-login)** — the app is a registered, confidential OIDC
17
+ client. Authorization Code Flow with PKCE, ID tokens verified against the
18
+ IdP's JWKS, sessions in encrypted cookies. **This is the one to use.**
19
+ - **[Gateway headers](#gateway-headers)** — the app trusts identity headers set
20
+ by a trusted proxy. No secret, no signature, no verification. Only defensible
21
+ when the app is unreachable except through that gateway.
22
+
23
+ On top of that, **[Relyper Coins](#relyper-coins)** lets an app spend from the
24
+ central wallets, authenticating with the same client credentials as the login.
25
+
26
+ ## OIDC login
27
+
28
+ ### 1. Register the service provider
29
+
30
+ In the Relyper IdP's admin UI under **OIDC Settings**: generate a client ID and
31
+ a client secret, set the redirect URI to `https://your-app.example/auth/callback`,
32
+ and list the application roles your app gates on under **Application Roles**.
33
+
34
+ The IdP stores only an Argon2id hash of the secret, so it cannot be read back
35
+ later. Copy it when it is generated.
36
+
37
+ ### 2. Register the plugin
18
38
 
19
39
  ```ts
20
40
  import Fastify from 'fastify';
21
- import { relyperAuth } from '@relyper/sp-auth/fastify';
41
+ import { relyperOidcAuth } from '@relyper/sp-auth/oidc/fastify';
22
42
 
23
43
  const app = Fastify();
24
44
 
25
- await app.register(relyperAuth, {
45
+ await app.register(relyperOidcAuth, {
46
+ issuer: process.env.RELYPER_OIDC_ISSUER, // https://api.relyper.de
47
+ clientId: process.env.RELYPER_OIDC_CLIENT_ID,
48
+ clientSecret: process.env.RELYPER_OIDC_CLIENT_SECRET,
49
+ redirectUri: process.env.RELYPER_OIDC_REDIRECT_URI,
50
+ sessionSecret: process.env.SESSION_SECRET, // 32+ chars, yours alone
51
+
26
52
  requiredRole: 'my_service_user',
27
53
  protect: (request) => request.url.startsWith('/api/'),
28
54
  meRoute: '/api/me',
55
+
29
56
  // Turn the IdP identity into your own user record.
30
57
  resolveUser: async (identity) => prisma.user.upsert({
31
58
  where: { idpSubject: identity.subject },
@@ -35,76 +62,136 @@ await app.register(relyperAuth, {
35
62
  });
36
63
 
37
64
  app.get('/api/cases', async (request) => {
38
- request.relyperIdentity; // { subject, email, displayName, roles }
65
+ request.relyperIdentity; // { subject, email, displayName, roles, tenantId, teams }
39
66
  request.principal; // whatever resolveUser returned
40
67
  });
41
68
  ```
42
69
 
43
- Declare the type of your own principal once:
70
+ That registers three routes `/auth/login`, `/auth/callback`, `/auth/logout` —
71
+ and guards everything `protect` selects.
72
+
73
+ `sessionSecret` is **not** the client secret. It is the key this app seals its
74
+ own cookies with, it never leaves the process, and rotating it logs everyone out.
75
+
76
+ ### 3. Drive it from the browser
44
77
 
45
78
  ```ts
46
- declare module 'fastify' {
47
- interface FastifyRequest {
48
- principal: { id: string; email: string };
49
- }
79
+ import { fetchRelyperSession, startRelyperLogin, startRelyperLogout } from '@relyper/sp-auth/client';
80
+
81
+ const session = await fetchRelyperSession<{ id: string; email: string }>();
82
+
83
+ switch (session.status) {
84
+ case 'authenticated': return session.user;
85
+ case 'unauthenticated': return startRelyperLogin({ loginUrl: session.loginUrl });
86
+ case 'forbidden': return showNoAccessScreen(session.message);
87
+ case 'error': return showError();
50
88
  }
51
89
  ```
52
90
 
53
- ## Without Fastify
91
+ `startRelyperLogin` is a full navigation, not a fetch: the IdP has to be able to
92
+ show its own login page and set its own cookie, which an XHR cannot do. It
93
+ remembers the current path and returns the user there afterwards.
54
94
 
55
- The core is a pure function over headers — no framework, no I/O:
95
+ ### What the flow guarantees
56
96
 
57
- ```ts
58
- import { createRelyperAuth } from '@relyper/sp-auth';
59
-
60
- const auth = createRelyperAuth({ requiredRole: 'my_service_user' });
61
- const result = auth.authenticate(request.headers); // Node headers or a fetch Headers object
97
+ | Step | What is checked |
98
+ | --- | --- |
99
+ | `/auth/login` | Fresh `state`, `nonce` and PKCE verifier, sealed into a short-lived encrypted cookie |
100
+ | `/auth/callback` | `state` matches this browser's login; no cookie means no callback |
101
+ | Token exchange | `client_secret_basic` (or `_post`), with the PKCE verifier |
102
+ | ID token | RS256 signature against the IdP's JWKS, plus `iss`, `aud`, `exp`, `nonce`, `azp`, `sub` |
103
+ | Algorithms | Pinned, so `alg: none` and HMAC-with-public-key are refused |
104
+ | UserInfo (optional) | `sub` must match the ID token's, or the response is discarded |
105
+ | Role | Checked at login **and** on every request afterwards |
106
+ | `returnTo` | Local paths only, so the login cannot become an open redirect |
107
+
108
+ The client secret only ever travels from your server to the IdP's token
109
+ endpoint. It never reaches the browser.
110
+
111
+ ### Sessions
112
+
113
+ The session is a cookie sealed with `sessionSecret` — encrypted (JWE, direct
114
+ A256GCM), not merely signed, so the browser cannot read the user's claims and
115
+ tampering fails to decrypt rather than yielding a forged value. The login cookie
116
+ and the session cookie use separate keys derived from that one secret, so
117
+ neither can be replayed as the other.
118
+
119
+ Sessions are stateless by default. That has one consequence worth knowing: a
120
+ logout clears the cookie in the browser that asked, but a cookie copied
121
+ beforehand keeps working until it expires. When that matters, keep a revocation
122
+ list:
62
123
 
63
- if (!result.ok) {
64
- return new Response(JSON.stringify({ error: result.message }), { status: result.status });
65
- }
66
- result.identity.subject;
124
+ ```ts
125
+ await app.register(relyperOidcAuth, {
126
+ // ...
127
+ onLogin: (result, request, sessionId) => revocations.remember(sessionId),
128
+ onLogout: (sessionId) => revocations.revoke(sessionId),
129
+ isSessionRevoked: (sessionId) => revocations.isRevoked(sessionId)
130
+ });
67
131
  ```
68
132
 
69
- `authenticate` never throws and never does I/O, which makes it easy to unit test
70
- and safe to call on every request.
133
+ ### Without Fastify
71
134
 
72
- ## Browser client
135
+ The client is framework-free:
73
136
 
74
137
  ```ts
75
- import { fetchRelyperSession } from '@relyper/sp-auth/client';
138
+ import { createRelyperOidcClient } from '@relyper/sp-auth/oidc';
76
139
 
77
- const session = await fetchRelyperSession<{ id: string; email: string }>();
140
+ const client = createRelyperOidcClient({ issuer, clientId, clientSecret, redirectUri });
78
141
 
79
- switch (session.status) {
80
- case 'authenticated': return session.user;
81
- case 'unauthenticated': return redirectToLogin();
82
- case 'forbidden': return showNoAccessScreen();
83
- case 'error': return showError();
84
- }
142
+ // Start
143
+ const { url, transaction } = await client.createAuthorizationRequest({ returnTo: '/cases/7' });
144
+ // Persist `transaction` for this browser, then redirect to `url`.
145
+
146
+ // Finish
147
+ const { identity, claims, tokens } = await client.completeLogin({ query, transaction });
85
148
  ```
86
149
 
87
- No framework dependency. A Vue composable or React hook around it is a few lines.
150
+ Every failure is a `RelyperOidcError` with a `code`, an HTTP `status`, a message
151
+ safe to show a user, and a `detail` safe to log.
88
152
 
89
- ## Options
153
+ ### OIDC options
90
154
 
91
155
  | Option | Default | Purpose |
92
156
  | --- | --- | --- |
93
- | `requiredRole` | – | Role(s) required for this service. Omit to only require an identity. |
94
- | `roleMatch` | `'any'` | With several required roles: one is enough, or all are needed. |
95
- | `requireEmail` | `true` | Set to `false` if your IdP does not send an address. |
96
- | `headerNames` | Relyper headers | Override individual header names for another gateway. |
97
- | `acceptForwardedHeaders` | `false` | Also accept `x-forwarded-*`. Off by default on purpose. |
98
- | `devAuth` | `false` | Local login without a gateway. Never enable in production. |
99
- | `unauthenticatedStatus` | `401` | Status when no identity arrives. |
100
- | `forbiddenStatus` | `403` | Status when the role is missing. |
101
- | `message` | per code | Fixed string or a function for the error message. |
102
- | `parseRoles` | comma-separated | Custom splitting of the roles header. |
103
-
104
- Fastify adapter additions: `protect`, `hook`, `resolveUser`, `principalKey`,
105
- `meRoute`, `meResponse`, `errorBody`, `onAuthFailure`, `warnOnDevAuth`.
106
-
107
- ## Headers
157
+ | `issuer` | – | Base URL of the IdP, as it appears in `iss`. Required. |
158
+ | `clientId` / `clientSecret` | | This app's registration. Required. |
159
+ | `redirectUri` | | Must match the registered URI byte for byte. Required. |
160
+ | `sessionSecret` | | Key for this app's own cookies, 32+ chars. Fastify adapter only. |
161
+ | `scope` | `openid email profile roles tenant teams` | Requested scopes. |
162
+ | `tokenEndpointAuthMethod` | from discovery | `client_secret_basic` or `client_secret_post`. |
163
+ | `requiredRole` / `roleMatch` | / `'any'` | Role gate. |
164
+ | `requireEmail` | `true` | Refuse a login with no address. |
165
+ | `useUserInfo` | `false` | Also call UserInfo; the Relyper IdP puts everything in the ID token. |
166
+ | `clockToleranceSeconds` | `60` | Leeway for `exp` / `iat`. |
167
+ | `discoveryTtlMs` | `3600000` | How long the discovery document is reused. |
168
+ | `requestTimeoutMs` | `10000` | Timeout for every call to the IdP. |
169
+ | `mapClaims` | `defaultClaimsToIdentity` | Custom claim mapping. |
170
+ | `fetch` | global | Custom fetch, used for discovery, tokens, UserInfo and JWKS alike. |
171
+
172
+ Fastify adapter additions: `sessionCookieName`, `loginCookieName`, `cookieDomain`,
173
+ `cookiePath`, `cookieSecure`, `sessionTtlSeconds`, `sessionAbsoluteTtlSeconds`,
174
+ `loginTtlSeconds`, `rollingSession`, `keepIdToken`, `loginPath`, `callbackPath`,
175
+ `logoutPath`, `postLogoutRedirect`, `loginErrorRedirect`, `protect`, `hook`,
176
+ `resolveUser`, `principalKey`, `meRoute`, `meResponse`,
177
+ `redirectUnauthenticated`, `errorBody`, `onAuthFailure`, `onLogin`, `onLogout`,
178
+ `isSessionRevoked`.
179
+
180
+ ## Gateway headers
181
+
182
+ The original integration, for services behind a gateway that authenticates on
183
+ their behalf.
184
+
185
+ ```ts
186
+ import { relyperAuth } from '@relyper/sp-auth/fastify';
187
+
188
+ await app.register(relyperAuth, {
189
+ requiredRole: 'my_service_user',
190
+ protect: (request) => request.url.startsWith('/api/'),
191
+ meRoute: '/api/me',
192
+ resolveUser
193
+ });
194
+ ```
108
195
 
109
196
  | Header | Meaning |
110
197
  | --- | --- |
@@ -116,30 +203,90 @@ Fastify adapter additions: `protect`, `hook`, `resolveUser`, `principalKey`,
116
203
  Fallbacks when `acceptForwardedHeaders` is on: `x-forwarded-user`,
117
204
  `x-forwarded-email`, `x-forwarded-preferred-username`, `x-forwarded-groups`.
118
205
 
119
- ## Security model read this
206
+ Options: `requiredRole`, `roleMatch`, `requireEmail`, `headerNames`,
207
+ `acceptForwardedHeaders`, `devAuth`, `unauthenticatedStatus`, `forbiddenStatus`,
208
+ `message`, `parseRoles`. Fastify additions: `protect`, `hook`, `resolveUser`,
209
+ `principalKey`, `meRoute`, `meResponse`, `errorBody`, `onAuthFailure`,
210
+ `warnOnDevAuth`.
120
211
 
121
- This package trusts headers. It does **not** verify a token or a signature. That
122
- is only safe when your service is unreachable except through a gateway that
123
- strips client-supplied `x-relyper-*` headers and sets them itself.
212
+ The core is a pure function over headers no framework, no I/O, never throws:
124
213
 
125
- If your service can be reached directly, anyone can send
126
- `x-relyper-roles: my_service_user` and be admitted. Two rules follow:
214
+ ```ts
215
+ import { createRelyperAuth } from '@relyper/sp-auth';
216
+
217
+ const auth = createRelyperAuth({ requiredRole: 'my_service_user' });
218
+ const result = auth.authenticate(request.headers);
219
+ if (!result.ok) return reply.code(result.status).send({ error: result.message });
220
+ result.identity.subject;
221
+ ```
222
+
223
+ ### Security model — read this
224
+
225
+ This path trusts headers. It verifies no token and no signature. It is only safe
226
+ when the service is unreachable except through a gateway that **overwrites**
227
+ client-supplied `x-relyper-*` headers rather than passing them through.
228
+
229
+ If the service can be reached directly, anyone can send
230
+ `x-relyper-roles: my_service_user` and be admitted. `acceptForwardedHeaders`
231
+ widens that surface further, because `x-forwarded-*` is what any generic proxy
232
+ sets — which is why it is off by default.
233
+
234
+ Prefer the OIDC login. It needs no such assumption about the network.
235
+
236
+ ## Relyper Coins
237
+
238
+ Relyper Coins are held centrally: the wallet at the identity provider is the
239
+ single source of truth for what a user can spend across every Relyper product.
240
+ A service provider never keeps its own balance.
241
+
242
+ ```ts
243
+ import { createRelyperCoinsClient, coinsBaseUrlFromIssuer, RelyperCoinsError } from '@relyper/sp-auth/coins';
244
+
245
+ const coins = createRelyperCoinsClient({
246
+ // The IdP publishes OIDC at the issuer root but mounts its API under /api.
247
+ baseUrl: coinsBaseUrlFromIssuer(process.env.RELYPER_OIDC_ISSUER),
248
+ clientId: process.env.RELYPER_OIDC_CLIENT_ID,
249
+ clientSecret: process.env.RELYPER_OIDC_CLIENT_SECRET
250
+ });
251
+
252
+ const wallet = await coins.getWallet(identity.subject);
253
+
254
+ try {
255
+ await coins.debit({
256
+ subject: identity.subject,
257
+ amountRc: 5,
258
+ reason: 'assistant.question',
259
+ idempotencyKey: 'my-app:' + requestHash, // a retry must not charge twice
260
+ product: 'my-app',
261
+ provider: 'openai',
262
+ model: 'gpt-x'
263
+ });
264
+ } catch (error) {
265
+ if (error instanceof RelyperCoinsError && error.code === 'insufficient_funds') {
266
+ // error.balanceRc / error.requestedRc
267
+ }
268
+ }
269
+ ```
127
270
 
128
- - Never expose the service port publicly without the gateway in front of it.
129
- - Never enable `devAuth` in production. It is off by default, and the Fastify
130
- adapter logs a warning the first time it is used.
271
+ The same client ID and secret as the OIDC login, so the IdP can attribute every
272
+ debit to one registered app and an operator can allow or revoke coin spending
273
+ per app. The app has to be ticked as **May consume Relyper Coins** in the IdP
274
+ admin UI, or `debit` throws with code `coins_not_enabled`.
131
275
 
132
- Token verification against the Relyper IdP (JWT/JWKS) is planned for a later
133
- version behind the same API, so switching should not require changes in calling
134
- code.
276
+ `debit` refuses to overdraw a wallet. Because the exact cost of a request is
277
+ usually only known after the work is done, check the balance against an estimate
278
+ first and debit the real amount afterwards.
135
279
 
136
280
  ## Design
137
281
 
138
- - `authenticate` is pure: headers in, result out. No database, no fetch, no throw.
282
+ - The OIDC client does no I/O you did not ask for: discovery and JWKS are
283
+ fetched lazily, cached, and go through your `fetch` if you supply one.
139
284
  - Identity and application user stay separate. The package hands you a
140
285
  `RelyperIdentity`; `resolveUser` maps it to your own record. That boundary is
141
286
  what makes the package reusable across service providers.
142
- - `subject` is the IdP ID and never the primary key of your database.
287
+ - `subject` is the IdP's ID and never the primary key of your database.
288
+ - Errors carry a user-safe `message` and a separate `detail` for logs, so an
289
+ IdP's diagnostics never leak into a response.
143
290
 
144
291
  ## License
145
292
 
package/dist/client.d.ts CHANGED
@@ -8,15 +8,20 @@ export type RelyperSession<TUser = RelyperIdentity> = {
8
8
  status: 'authenticated';
9
9
  user: TUser;
10
10
  }
11
- /** No identity passed through the gateway. Typically: login required. */
11
+ /**
12
+ * Nobody is signed in. With the OIDC integration the server also names the
13
+ * route that starts a login; {@link startRelyperLogin} sends the browser there.
14
+ */
12
15
  | {
13
16
  status: 'unauthenticated';
14
17
  response: Response;
18
+ loginUrl: string;
15
19
  }
16
20
  /** Signed in, but without the role required for this service provider. */
17
21
  | {
18
22
  status: 'forbidden';
19
23
  response: Response;
24
+ message?: string;
20
25
  } | {
21
26
  status: 'error';
22
27
  response: Response;
@@ -30,8 +35,35 @@ export type FetchSessionOptions = {
30
35
  signal?: AbortSignal;
31
36
  /** Default: 'same-origin'. */
32
37
  credentials?: RequestCredentials;
38
+ /** Fallback when the server names no login route. Default: '/auth/login'. */
39
+ loginPath?: string;
33
40
  };
34
41
  export declare function fetchRelyperSession<TUser = RelyperIdentity>(options?: FetchSessionOptions): Promise<RelyperSession<TUser>>;
42
+ export type StartLoginOptions = {
43
+ /** Route that starts the login. Default: '/auth/login'. */
44
+ loginUrl?: string;
45
+ /**
46
+ * Where to land after the login. Default: the current path including query
47
+ * and hash, so the user resumes exactly where the session ran out.
48
+ */
49
+ returnTo?: string;
50
+ };
51
+ /**
52
+ * Sends the browser into the OIDC login.
53
+ *
54
+ * A full navigation, not a fetch: the identity provider has to be able to show
55
+ * its own login page and set its own session cookie, which an XHR cannot do.
56
+ * `location.replace` keeps the expired page out of the back-button history.
57
+ */
58
+ export declare function startRelyperLogin(options?: StartLoginOptions): void;
59
+ /**
60
+ * Ends the session. A form POST rather than fetch, so the browser follows the
61
+ * server's redirect and, where the identity provider supports it, the logout
62
+ * continues on to end the session there too.
63
+ */
64
+ export declare function startRelyperLogout(options?: {
65
+ logoutUrl?: string;
66
+ }): void;
35
67
  /** true if the identity has at least one of the given roles. */
36
68
  export declare function hasAnyRole(identity: Pick<RelyperIdentity, 'roles'>, roles: string[]): boolean;
37
69
  export type { RelyperIdentity };
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;GAIG;AAEH,MAAM,MAAM,cAAc,CAAC,KAAK,GAAG,eAAe,IAC9C;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE;AAC1C,yEAAyE;GACvE;IAAE,MAAM,EAAE,iBAAiB,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE;AACnD,0EAA0E;GACxE;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAC3C;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAE5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,0BAA0B;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,kBAAkB,CAAC;CAClC,CAAC;AAEF,wBAAsB,mBAAmB,CAAC,KAAK,GAAG,eAAe,EAC/D,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAkBhC;AAED,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAE7F;AAED,YAAY,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;GAIG;AAEH,MAAM,MAAM,cAAc,CAAC,KAAK,GAAG,eAAe,IAC9C;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE;AAC1C;;;GAGG;GACD;IAAE,MAAM,EAAE,iBAAiB,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AACrE,0EAA0E;GACxE;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAE5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,0BAA0B;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAIF,wBAAsB,mBAAmB,CAAC,KAAK,GAAG,eAAe,EAC/D,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAkChC;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,iBAAsB,GAAG,IAAI,CASvE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAS7E;AAED,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAE7F;AAUD,YAAY,EAAE,eAAe,EAAE,CAAC"}
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ const DEFAULT_LOGIN_PATH = '/auth/login';
1
2
  export async function fetchRelyperSession(options = {}) {
2
3
  const doFetch = options.fetch ?? globalThis.fetch;
3
4
  if (!doFetch)
@@ -5,21 +6,73 @@ export async function fetchRelyperSession(options = {}) {
5
6
  const response = await doFetch(options.path ?? '/api/me', {
6
7
  method: 'GET',
7
8
  credentials: options.credentials ?? 'same-origin',
9
+ // Without this the browser would follow a redirect to the identity provider
10
+ // inside the request, and the caller would see an opaque cross-origin
11
+ // failure instead of the status it can act on.
12
+ redirect: 'manual',
8
13
  headers: { accept: 'application/json', ...(options.headers ?? {}) },
9
14
  signal: options.signal
10
15
  });
11
- if (response.status === 401)
12
- return { status: 'unauthenticated', response };
13
- if (response.status === 403)
14
- return { status: 'forbidden', response };
16
+ if (response.status === 401 || response.type === 'opaqueredirect') {
17
+ const body = await readJsonBody(response);
18
+ const loginUrl = typeof body?.loginUrl === 'string' && body.loginUrl
19
+ ? body.loginUrl
20
+ : options.loginPath ?? DEFAULT_LOGIN_PATH;
21
+ return { status: 'unauthenticated', response, loginUrl };
22
+ }
23
+ if (response.status === 403) {
24
+ const body = await readJsonBody(response);
25
+ const message = typeof body?.error === 'string' ? body.error : undefined;
26
+ return { status: 'forbidden', response, message };
27
+ }
15
28
  if (!response.ok)
16
29
  return { status: 'error', response };
17
30
  const body = (await response.json());
18
31
  const user = body.user ?? body;
19
32
  return { status: 'authenticated', user };
20
33
  }
34
+ /**
35
+ * Sends the browser into the OIDC login.
36
+ *
37
+ * A full navigation, not a fetch: the identity provider has to be able to show
38
+ * its own login page and set its own session cookie, which an XHR cannot do.
39
+ * `location.replace` keeps the expired page out of the back-button history.
40
+ */
41
+ export function startRelyperLogin(options = {}) {
42
+ if (typeof window === 'undefined') {
43
+ throw new Error('@relyper/sp-auth/client: startRelyperLogin needs a browser environment.');
44
+ }
45
+ const base = options.loginUrl ?? DEFAULT_LOGIN_PATH;
46
+ const returnTo = options.returnTo
47
+ ?? window.location.pathname + window.location.search + window.location.hash;
48
+ const separator = base.includes('?') ? '&' : '?';
49
+ window.location.replace(base + separator + 'returnTo=' + encodeURIComponent(returnTo));
50
+ }
51
+ /**
52
+ * Ends the session. A form POST rather than fetch, so the browser follows the
53
+ * server's redirect and, where the identity provider supports it, the logout
54
+ * continues on to end the session there too.
55
+ */
56
+ export function startRelyperLogout(options = {}) {
57
+ if (typeof window === 'undefined') {
58
+ throw new Error('@relyper/sp-auth/client: startRelyperLogout needs a browser environment.');
59
+ }
60
+ const form = window.document.createElement('form');
61
+ form.method = 'POST';
62
+ form.action = options.logoutUrl ?? '/auth/logout';
63
+ window.document.body.appendChild(form);
64
+ form.submit();
65
+ }
21
66
  /** true if the identity has at least one of the given roles. */
22
67
  export function hasAnyRole(identity, roles) {
23
68
  return roles.some((role) => identity.roles.includes(role));
24
69
  }
70
+ async function readJsonBody(response) {
71
+ try {
72
+ return (await response.clone().json());
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
25
78
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AA2BA,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,OAAO,GAAwB,EAAE;IAEjC,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IAClD,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;IAE7F,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,SAAS,EAAE;QACxD,MAAM,EAAE,KAAK;QACb,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,aAAa;QACjD,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE,GAAG,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE;QACnE,MAAM,EAAE,OAAO,CAAC,MAAM;KACvB,CAAC,CAAC;IAEH,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC;IAC5E,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;IACtE,IAAI,CAAC,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAEvD,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA6B,CAAC;IACjE,MAAM,IAAI,GAAI,IAAyB,CAAC,IAAI,IAAK,IAAc,CAAC;IAChE,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,CAAC;AAC3C,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,UAAU,CAAC,QAAwC,EAAE,KAAe;IAClF,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;AAC7D,CAAC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Client for the Relyper Coins integration API.
3
+ *
4
+ * Relyper Coins are held centrally: the wallet at the identity provider is the
5
+ * single source of truth for what a user can spend across every Relyper product.
6
+ * A service provider therefore never keeps its own balance -- it asks, and it
7
+ * debits, and anything it stores locally is a mirror for its own reporting.
8
+ *
9
+ * The service provider authenticates with the same client ID and secret it uses
10
+ * for the OIDC login, so every debit is attributable to one registered client
11
+ * and an operator can allow or refuse coin spending per app.
12
+ */
13
+ export type RelyperCoinWallet = {
14
+ ownerKind: 'user' | 'tenant';
15
+ ownerId: string;
16
+ balanceRc: number;
17
+ lifetimeEarnedRc: number;
18
+ lifetimeSpentRc: number;
19
+ reputationPoints: number;
20
+ convertibleRc: number;
21
+ planTier: string | null;
22
+ planMonthlyAllowanceRc: number;
23
+ };
24
+ export type RelyperCoinLedgerEntry = {
25
+ direction: 'credit' | 'debit';
26
+ amountRc: number;
27
+ balanceAfterRc: number;
28
+ reason: string;
29
+ product: string | null;
30
+ provider: string | null;
31
+ model: string | null;
32
+ clientId: string | null;
33
+ createdAt: string;
34
+ };
35
+ export type RelyperCoinDebit = {
36
+ /** IdP subject of the user, i.e. `identity.subject` from the login. */
37
+ subject: string;
38
+ amountRc: number;
39
+ /** Short machine-readable reason, e.g. 'assistant.question'. */
40
+ reason: string;
41
+ /**
42
+ * Makes the debit repeatable without double-charging. Strongly recommended:
43
+ * a retry after a timeout would otherwise bill the user twice.
44
+ */
45
+ idempotencyKey?: string;
46
+ /** Product label shown in the identity provider's reporting. */
47
+ product?: string;
48
+ provider?: string;
49
+ model?: string;
50
+ tenantId?: string;
51
+ meta?: Record<string, unknown>;
52
+ };
53
+ export type RelyperCoinDebitResult = {
54
+ wallet: RelyperCoinWallet;
55
+ entry: RelyperCoinLedgerEntry | null;
56
+ /** True when this idempotency key had already been booked; nothing was charged again. */
57
+ deduplicated: boolean;
58
+ };
59
+ export type RelyperCoinsErrorCode =
60
+ /** The wallet does not hold enough coins. */
61
+ 'insufficient_funds'
62
+ /** This client is registered but not permitted to spend coins. */
63
+ | 'coins_not_enabled'
64
+ /** Client credentials rejected. */
65
+ | 'unauthorized'
66
+ /**
67
+ * Refused with 403 without saying it was the coin permission. Usually the
68
+ * request never reached the coins service: a wrong base URL, a gateway or a
69
+ * WAF. Read `url` and `responseBody` on the error.
70
+ */
71
+ | 'forbidden'
72
+ /** The request was malformed. */
73
+ | 'invalid_request'
74
+ /** The identity provider was unreachable or answered unexpectedly. */
75
+ | 'unavailable';
76
+ export declare class RelyperCoinsError extends Error {
77
+ readonly code: RelyperCoinsErrorCode;
78
+ readonly status: number;
79
+ /** Present on insufficient_funds. */
80
+ readonly balanceRc?: number;
81
+ readonly requestedRc?: number;
82
+ readonly cause?: unknown;
83
+ /**
84
+ * What the service actually answered, and where it was asked.
85
+ *
86
+ * Kept because a status code alone does not identify a cause: a 403 from the
87
+ * coins service and a 403 from a proxy in front of it are the same number and
88
+ * mean entirely different things. Without the body, an operator holding a
89
+ * correctly configured client has nothing to go on but a message this library
90
+ * guessed. `responseError` is the service's own error code when it sent one.
91
+ */
92
+ readonly url?: string;
93
+ readonly responseError?: string;
94
+ readonly responseBody?: string;
95
+ constructor(code: RelyperCoinsErrorCode, message: string, options?: {
96
+ status?: number;
97
+ balanceRc?: number;
98
+ requestedRc?: number;
99
+ cause?: unknown;
100
+ url?: string;
101
+ responseError?: string;
102
+ responseBody?: string;
103
+ });
104
+ }
105
+ export type RelyperCoinsOptions = {
106
+ /**
107
+ * Base URL of the identity provider's API, including the path prefix its
108
+ * routes are mounted under. For Relyper that is the issuer plus `/api`, e.g.
109
+ * `https://api.relyper.de/api` for issuer `https://api.relyper.de`.
110
+ */
111
+ baseUrl: string;
112
+ /** Same credentials as the OIDC login. */
113
+ clientId: string;
114
+ clientSecret: string;
115
+ /** Default: 10000. */
116
+ requestTimeoutMs?: number;
117
+ fetch?: typeof globalThis.fetch;
118
+ };
119
+ export type RelyperCoinsClient = {
120
+ /** Balance and plan allowance of one user's wallet. */
121
+ getWallet(subject: string): Promise<RelyperCoinWallet>;
122
+ /** Recent ledger entries for one user. */
123
+ getLedger(subject: string, limit?: number): Promise<RelyperCoinLedgerEntry[]>;
124
+ /**
125
+ * Spends coins. Throws {@link RelyperCoinsError} with code `insufficient_funds`
126
+ * when the wallet cannot cover the amount -- the debit is refused, not
127
+ * overdrawn.
128
+ */
129
+ debit(input: RelyperCoinDebit): Promise<RelyperCoinDebitResult>;
130
+ };
131
+ /**
132
+ * Derives the coins API base URL from an OIDC issuer.
133
+ *
134
+ * The Relyper IdP publishes OIDC at the issuer root but mounts its regular API
135
+ * under `/api`, so the two differ by exactly that segment.
136
+ */
137
+ export declare function coinsBaseUrlFromIssuer(issuer: string): string;
138
+ export declare function createRelyperCoinsClient(options: RelyperCoinsOptions): RelyperCoinsClient;
139
+ //# sourceMappingURL=coins.d.ts.map