@hyper.authio/client 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,214 @@
1
- # Temporary Holding Version
1
+ # @hyper.authio/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Authio client for Node.js 20.19+ and edge runtimes: discovery, interactive sign-in (PKCE, ID tokens, end session,
4
+ back-channel logout tokens, step-up), every grant, client authentication with a secret or `private_key_jwt`,
5
+ introspection, revocation, UserInfo, JWE, a cached client-credentials `fetch`, account credentials for the Admin API,
6
+ the roles and permissions of a token, and webhook verification. Its only dependency is
7
+ [`jose`](https://github.com/panva/jose).
8
+
9
+ ```bash
10
+ npm install @hyper.authio/client
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ ```ts
16
+ import { createAuthioClient } from "@hyper.authio/client";
17
+
18
+ const authio = createAuthioClient({
19
+ issuer: "https://auth.example.com/realms/acme", // or authority + realm
20
+ clientId: "orders-worker",
21
+ clientSecret: process.env.AUTHIO_CLIENT_SECRET,
22
+ });
23
+ ```
24
+
25
+ | Option | Default | |
26
+ | --- | --- | --- |
27
+ | `issuer`, or `authority` + `realm` | | The realm's issuer. Must be HTTPS, except on loopback or with `requireHttps: false`. |
28
+ | `clientId` | | Required. |
29
+ | `clientAuthentication` | `private_key_jwt` with only a key, else `client_secret_basic` | Also `client_secret_post` and `none` (a public client). |
30
+ | `clientSecret` | | For `client_secret_basic` / `client_secret_post`. A function returning the secret is called for every request, so a rotated secret is used without a restart. |
31
+ | `clientPrivateKey`, `clientPrivateKeyPath`, `clientKeyId`, `clientSigningAlgorithm` | RS256 for RSA, the curve's ES algorithm for EC | For `private_key_jwt`: PEM (PKCS#8, PKCS#1, SEC1) or JWK; the file is re-read when it changes; RSA keys need 2048 bits. |
32
+ | `clientAssertionLifetime` | 120 s | At most 3600. |
33
+ | `clientEncryptionKey`, `clientEncryptionKeyPath` | | The private half of the client's encryption key (JWK or PEM, or a function returning it; the path is Node only), to read ID tokens, UserInfo and logout tokens Authio encrypts (RSA-OAEP, RSA-OAEP-256). |
34
+ | `allowLegacyEncryptionAlgorithms` | `false` | Accepted for parity; RSA1_5 stays refused in Node, which cannot decrypt it (`legacy_algorithm`). |
35
+ | `redirectUri`, `postLogoutRedirectUri` | | Defaults for sign-in and sign-out requests. |
36
+ | `clockSkew` | 30 s | Tolerated on ID tokens, logout tokens and signed UserInfo. |
37
+ | `validAlgorithms` | RS*, PS*, ES* | Accepted signing algorithms for those tokens. |
38
+ | `discoveryCacheDuration` | 3600 s | |
39
+ | `httpTimeout` | 30 s | |
40
+ | `tokenRefreshSkew` | 60 s | How early cached tokens are renewed (at most half their lifetime). |
41
+ | `fetch`, `clock` | global `fetch`, system clock | Replace them in tests. |
42
+
43
+ Every endpoint is read from the discovery document, which is cached, read once for concurrent callers, and refused
44
+ when it names another issuer.
45
+
46
+ Option names follow the shared `conformance/options.md`; there are no aliases for older names.
47
+
48
+ ## Sign people in
49
+
50
+ ```ts
51
+ const authio = createAuthioClient({
52
+ issuer, clientId: "storefront", clientAuthentication: "none", // or a secret / private key for a confidential client
53
+ redirectUri: "https://shop.example.com/callback",
54
+ });
55
+
56
+ // 1. Send the person to Authio, keeping the transaction (session, encrypted cookie…).
57
+ const { url, transaction } = await authio.createAuthorizationUrl({
58
+ scopes: ["openid", "profile", "offline_access"],
59
+ resource: "https://orders.example.com", // RFC 8707
60
+ organization: "acme-europe", // optional: sign in for one organization
61
+ prompt: "login", // none | login | select_account | consent
62
+ maxAge: 900, acrValues: "2", loginHint, uiLocales: "pt-BR", idpHint: "google",
63
+ });
64
+
65
+ // 2. On the callback: state and iss (RFC 9207) are checked, the code is redeemed with the PKCE verifier,
66
+ // and the ID token validated (nonce, at_hash, auth_time against maxAge).
67
+ const { tokens, idToken } = await authio.handleAuthorizationCallback(request.url, transaction);
68
+ ```
69
+
70
+ PKCE uses S256 unless `codeChallengeMethod: "plain"` is asked for. `popup: true` sets `display=popup` and
71
+ `response_mode=web_message`; pass the parameters the popup posted to the opener to `handleAuthorizationCallback`. A
72
+ callback that is not for the transaction throws `AuthioCallbackError` (`reason`: `state_mismatch`, `issuer_mismatch`,
73
+ `missing_issuer`, `missing_code`); an OAuth error Authio sent back (`login_required` after `prompt: "none"`, for one)
74
+ throws `AuthioProtocolError`.
75
+
76
+ ### ID tokens and sign-out
77
+
78
+ ```ts
79
+ const claims = await authio.validateIdToken(idToken, { nonce, accessToken, maxAge }); // AuthioTokenValidationError otherwise
80
+ const logoutUrl = await authio.createEndSessionUrl({ idTokenHint, postLogoutRedirectUri, state });
81
+ const logout = await authio.validateLogoutToken(form.logout_token); // { subject, sessionId, tokenId, claims }
82
+ ```
83
+
84
+ ID tokens are checked against the realm's JWKS (cached, re-read on an unknown `kid`): `iss`, `aud` = the client id,
85
+ `exp` with `clockSkew`, payload `typ` = `ID`, `azp` when there are several audiences, `nonce`, `at_hash` (hash of the
86
+ signing algorithm) and `auth_time` against `maxAge`. An encrypted ID token is decrypted with `clientEncryptionKey`
87
+ first. A back-channel logout token must be typed `logout+jwt`, carry the back-channel event, no `nonce`, and a `sub`
88
+ or `sid`. Every refusal is an `AuthioTokenValidationError` whose `reason` uses the outcome names of
89
+ `conformance/tokens.json` (`invalid_nonce`, `invalid_at_hash`, `invalid_logout_token`, `expired`, …). In Express,
90
+ `authioBackchannelLogout` from `@hyper.authio/express` is the endpoint.
91
+
92
+ ### Step-up
93
+
94
+ When an API answers `403` with `WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="2",
95
+ max_age=300` (RFC 9470):
96
+
97
+ ```ts
98
+ const challenge = parseAuthioBearerChallenge(response.headers.get("www-authenticate"));
99
+ const { url, transaction } = await authio.createStepUpAuthorizationUrl(challenge!); // acr_values, max_age, prompt=login for max_age=0
100
+ ```
101
+
102
+ ## Grants
103
+
104
+ ```ts
105
+ await authio.tokens.clientCredentials({ scopes: ["orders:read"], resource: "https://orders.example.com" });
106
+ await authio.tokens.authorizationCode({ code, redirectUri, codeVerifier });
107
+ await authio.tokens.refresh({ refreshToken }); // store the rotated refresh_token it returns
108
+ await authio.tokens.exchange({ subjectToken, resource }); // on-behalf-of (RFC 8693)
109
+ await authio.tokens.password({ username, password });
110
+ await authio.tokens.personalAccessToken({ token: "pat_…", clientId: "billing-api" }); // clientId only for a consumer's PAT
111
+
112
+ const device = await authio.tokens.deviceAuthorization({ scopes: "openid" });
113
+ console.log(device.verification_uri_complete);
114
+ const token = await authio.tokens.pollDevice(device); // waits, slows down, throws access_denied / expired_token
115
+
116
+ const connection = await authio.tokens.exchangeForConnection({ subjectToken, provider: "github" });
117
+ switch (connection.status) {
118
+ case "granted": /* accessToken, issuedTokenType, tokenType, expiresIn, scope, connectionId, provider, providerType, externalAccount */ break;
119
+ case "required": /* send the person to connection.connectUri */ break;
120
+ case "selection_required": /* ask again with one of connection.connections[].id (label, externalLogin) */ break;
121
+ case "refused": /* connection.error, connection.errorCode */ break;
122
+ }
123
+ ```
124
+
125
+ Device polling waits `interval`, keeps polling on `authorization_pending`, adds 5 seconds on `slow_down`, and stops
126
+ with the error on `access_denied` and `expired_token` (`toDevicePollOutcome` tells which).
127
+
128
+ OAuth errors throw `AuthioProtocolError` (`error`, `errorDescription`, `errorCode`, `status`); `error` is
129
+ `http_{status}` when Authio sent none, `invalid_response` when a success body could not be read. Tokens print as
130
+ `[redacted]` in `console.log` and `util.inspect`, and serialize normally.
131
+
132
+ ## Machine-to-machine calls
133
+
134
+ ```ts
135
+ const ordersFetch = authio.clientCredentialsFetch({ scopes: "orders:read", resource: "https://orders.example.com" });
136
+ await ordersFetch("https://orders.example.com/orders");
137
+ ```
138
+
139
+ The token is cached per scopes and resource, renewed before expiry with one request for concurrent callers, and on a
140
+ `401` dropped and retried once. `authio.clientCredentials.getToken()` gives the token itself.
141
+
142
+ ## Introspection, revocation, UserInfo
143
+
144
+ ```ts
145
+ const result = await authio.introspect(token); // { active, sub, scope, …, and Authio's extra members }
146
+ await authio.revoke(refreshToken, { tokenTypeHint: "refresh_token" });
147
+ const info = await authio.userInfo(accessToken); // JSON, or a signed / encrypted JWT verified for you
148
+ ```
149
+
150
+ Authio answers introspection according to who asks: a token requested for a resource is active only to the client
151
+ that resource server names as its introspection client.
152
+
153
+ ## Admin API credentials
154
+
155
+ ```ts
156
+ import { createAuthioAccountCredential, readAuthioAdminResponse } from "@hyper.authio/client";
157
+
158
+ const credential = createAuthioAccountCredential({
159
+ authority: "https://auth.example.com",
160
+ credential: process.env.AUTHIO_CREDENTIAL, // ak_…, cs_… or pat_…
161
+ credentialId: process.env.AUTHIO_CREDENTIAL_ID, // needed for a cs_ secret
162
+ });
163
+ const response = await credential.fetch("https://auth.example.com/api/v1/admin/realms");
164
+ const { data } = await readAuthioAdminResponse(response); // { items, totalCount, pageNumber, pageSize } for a listing; AuthioAdminError otherwise
165
+ ```
166
+
167
+ `ak_` and `pat_` go directly as `Authorization: Bearer` (or `x-api-key` with `presentAs`); a `cs_` secret is exchanged
168
+ at `{authority}/realms/authio` with `client_credentials` and the `client_id` `cred_{id}`, and a `pat_` may be exchanged
169
+ with the `personal_access_token` grant (`exchangePersonalAccessToken: true`). The `fetch` retries an idempotent request
170
+ once after `Retry-After` on `429`/`503`. `AuthioAdminError` carries `status` (the HTTP one), `errorCode`,
171
+ `notifications`, `requestId` (body or `X-Request-Id`) and `retryAfterSeconds`; `parseAuthioAdminEnvelope` reads an
172
+ answer you fetched yourself. The full Admin client comes in a later release.
173
+
174
+ ## Roles and permissions
175
+
176
+ ```ts
177
+ import { parseResourceAccess } from "@hyper.authio/client";
178
+
179
+ const access = parseResourceAccess(jwtPayload); // or introspection answer, or [type, value] pairs
180
+ access.roles; // ["billing-admin", "viewer"]
181
+ access.permissions; // { invoice: ["read", "write"], report: ["read"] }
182
+ access.grants("invoice", "write"); // true, case-insensitive
183
+ access.grants("report", "read", ["user"]); // only roles from user_resource_access
184
+ access.fromSource("client"); // the client's roles
185
+ ```
186
+
187
+ The seven `*_resource_access` claims (`user`, `realm`, `client`, `plan`, `consumer`, `secret`, `system`) are merged.
188
+ A claim that is not exactly `{ role: { resource: [method] } }` contributes nothing, as in Authio's own check. To
189
+ protect an API, use [`@hyper.authio/server`](../server/README.md), which also reads groups, mapper claims and the rest.
190
+
191
+ ## Webhooks
192
+
193
+ ```ts
194
+ import { verifyWebhookSignature, parseWebhookEvent, asUserEvent } from "@hyper.authio/client";
195
+
196
+ const result = await verifyWebhookSignature(request.headers["x-authio-signature"], rawBody, secret);
197
+ if (!result.valid) return reply(401);
198
+
199
+ const event = parseWebhookEvent(rawBody);
200
+ const user = asUserEvent(event); // { id, username, emailConfirmed, … } for user_created / user_updated / user_deleted, else undefined
201
+ ```
202
+
203
+ Every event type has its accessor (`asLoginEvent`, `asUserLockedOutEvent`, `asTokenRevokedEvent`,
204
+ `asKeysRotatedEvent`, `asTokenReuseDetectedEvent`, `asAdminResourceEvent`, `asUserSuspensionEvent`,
205
+ `asAttackDetectedEvent`, `asCredentialCompromisedEvent`, `asPingEvent`), which exposes every member of `data` in
206
+ camelCase with its value unchanged; `event.data` stays as delivered (snake_case). An unknown type still parses
207
+ (`isKnownWebhookEventType` is `false`, `event.data` is the raw JSON).
208
+
209
+ The signature is HMAC-SHA256 of `{t}.{body}`, compared in constant time; the timestamp must be within 5 minutes.
210
+ [`@hyper.authio/server`](../server/README.md) adds deduplication and dispatch.
211
+
212
+ ## License
213
+
214
+ MIT