@dewee/identity 0.0.0-stage → 0.1.0-beta.1
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 +95 -2
- package/dist/assertion.d.ts +64 -0
- package/dist/assertion.js +155 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +2 -0
- package/dist/errors.d.ts +66 -0
- package/dist/errors.js +174 -0
- package/dist/exchange.d.ts +51 -0
- package/dist/exchange.js +74 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/job-access.d.ts +26 -0
- package/dist/job-access.js +182 -0
- package/dist/lifecycle.d.ts +50 -0
- package/dist/lifecycle.js +67 -0
- package/dist/links.d.ts +99 -0
- package/dist/links.js +140 -0
- package/dist/transport.d.ts +40 -0
- package/dist/transport.js +103 -0
- package/dist/verify-trusted-user.d.ts +76 -0
- package/dist/verify-trusted-user.js +173 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dewee/identity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server SDK of Dewee Connect: sign your users in to a Dewee agent, answer the hosted-link handoff,
|
|
4
|
+
report user lifecycle changes, and read the verified user inside your MCP server.
|
|
5
|
+
|
|
6
|
+
- Server only. Every entry point refuses to run where `window` exists: it holds your application
|
|
7
|
+
secrets.
|
|
8
|
+
- Node 20.3 or later, ESM. One dependency: [`jose`](https://github.com/panva/jose). Signing and
|
|
9
|
+
verification use WebCrypto, so the token format also works in Workers-style runtimes.
|
|
10
|
+
- Secrets, tokens and assertions never appear in an error message or in `JSON.stringify(error)`.
|
|
11
|
+
|
|
12
|
+
Status: beta (`0.x`). The contract follows the gateway's
|
|
13
|
+
[OpenAPI spec](https://github.com/nextlevelbuilder/dewee/blob/dev/internal/http/openapi_spec.json) (tag `Identity`); a test fails when the two
|
|
14
|
+
drift apart.
|
|
15
|
+
|
|
16
|
+
Not published to npm yet. Until it is, build and pack it from this repository, then install the
|
|
17
|
+
tarball in your app; once published, use `npm install @dewee/identity` instead.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm install && pnpm --filter @dewee/identity build
|
|
21
|
+
cd packages/dewee-identity && pnpm pack # writes dewee-identity-<version>.tgz
|
|
22
|
+
npm install /path/to/dewee-identity-<version>.tgz # in your app
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The full walkthrough, with curl equivalents for other languages, is the
|
|
26
|
+
[Dewee Connect integration recipe](https://github.com/nextlevelbuilder/dewee/blob/dev/docs/dewee-connect-integration.md). A runnable app is in
|
|
27
|
+
[`examples/identity-partner-sample`](https://github.com/nextlevelbuilder/dewee/blob/dev/examples/identity-partner-sample/README.md).
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
| Function | Call |
|
|
32
|
+
|----------|------|
|
|
33
|
+
| `exchangeSession({gatewayUrl, signer, subject, agent, ttlSeconds?})` | `POST /v1/identity/sessions/exchange`. Returns `{sessionToken, header, sessionId, principalId, applicationId, agentId, expiresAt, testMode, testModeBanner?}`. Also accepts a ready `assertion` instead of `signer` and `subject`. |
|
|
34
|
+
| `startLink({gatewayUrl, sessionToken, idempotencyKey?})` | `POST /v1/identity/links` (web-first). Returns the proof code and the chat accounts it can be sent to. |
|
|
35
|
+
| `readConnectRequest(url, {connectBaseUrl})` | Reads and checks `dewee_txn` and `return_to` on your `connect_start_url`. |
|
|
36
|
+
| `createConnectRedirect({signer, subject, connectBaseUrl, deweeTxn, returnTo, cspNonce?})` | Signs the handoff assertion and returns the auto-submitting POST form (`html`). A strict CSP blocks the inline submit unless you pass your page's `cspNonce`; the user then presses the Continue button. |
|
|
37
|
+
| `lifecycle({gatewayUrl, signer, subject, event, scopes?})` | `POST /v1/identity/principals/lifecycle`: `suspend`, `reactivate`, `delete`, `scopes_changed`. |
|
|
38
|
+
| `revokeSession({gatewayUrl, signer, subject, sessionId})` | `POST /v1/identity/sessions/{id}/revoke`. |
|
|
39
|
+
| `logout({gatewayUrl, signer, subject, principalId})` | `POST /v1/identity/principals/{id}/logout`. |
|
|
40
|
+
| `verifyTrustedUser(params, {mcpContextSecret, serverId, issuer, tenantId, applicationId, replayGuard?})` | Verifies `params._meta.dewee.trusted_user` in your MCP server and returns the user. |
|
|
41
|
+
| `createAssertion({...signer, purpose, subject, ...})` | The signed assertion itself, for a call you make on your own. |
|
|
42
|
+
|
|
43
|
+
A `signer` is `{applicationId, issuer, secret}` for an `hmac` application, or
|
|
44
|
+
`{applicationId, issuer, privateKeyJwk, algorithm}` (RS256, ES256 or EdDSA, with a `kid`) for a
|
|
45
|
+
`jwks` one. The `subject` is your stable user id: at most 255 bytes and never an email address.
|
|
46
|
+
The SDK refuses any subject containing `@`, which is stricter than the gateway's email-shape check.
|
|
47
|
+
|
|
48
|
+
`verifyTrustedUser` accepts each `jti` once. Its default `MemoryReplayGuard` keeps up to 100,000
|
|
49
|
+
unexpired ids in process memory and fails closed when full (every new token is refused with reason
|
|
50
|
+
`replay` until ids expire). Use it for development or a single process; in production pass a
|
|
51
|
+
shared `replayGuard` (Redis `SET NX EX`, a database row with a unique key).
|
|
52
|
+
|
|
53
|
+
Gateway calls take `gatewayUrl` (https, or http on localhost; `allowInsecureHttp` for a private
|
|
54
|
+
network), `timeoutMs` (default 10000), `signal` and `fetch`.
|
|
55
|
+
|
|
56
|
+
## Errors
|
|
57
|
+
|
|
58
|
+
Every failure is a `DeweeIdentityError`:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
try {
|
|
62
|
+
await exchangeSession(opts);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err instanceof DeweeIdentityError) {
|
|
65
|
+
err.code; // IDENTITY_REQUIRED, LINK_REVOKED, RESOURCE_EXHAUSTED, TRANSPORT_ERROR, ...
|
|
66
|
+
err.status; // HTTP status, 0 when no answer arrived
|
|
67
|
+
err.remediation; // {action, audience} for identity errors
|
|
68
|
+
err.retryAfterSeconds; // set on 429
|
|
69
|
+
err.retryable; // true for rate limits and unavailable sources
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`verifyTrustedUser` refuses with `IDENTITY_REQUIRED` and a `reason` (`signature`, `audience`,
|
|
75
|
+
`issuer`, `tenant`, `application`, `expired`, `replay`, and so on) for your logs. A missing
|
|
76
|
+
verification setting is `INVALID_ARGUMENT`: all four bindings (server, issuer, tenant,
|
|
77
|
+
application) are required.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
pnpm --filter @dewee/identity typecheck
|
|
83
|
+
pnpm --filter @dewee/identity test # unit and cross-language fixture tests
|
|
84
|
+
pnpm --filter @dewee/identity lint # build + publint --strict
|
|
85
|
+
DEWEE_IT_GATEWAY_URL=http://127.0.0.1:18790 DEWEE_IT_GATEWAY_TOKEN=... \
|
|
86
|
+
pnpm --filter @dewee/identity test:integration # against a live gateway with an agent "sdk-it"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The fixtures in `test/fixtures` are shared with the Go tests: `pnpm --filter @dewee/identity
|
|
90
|
+
fixtures:generate` rewrites the SDK-signed assertions that `internal/identity/assertion` verifies,
|
|
91
|
+
and `DEWEE_UPDATE_SDK_FIXTURES=1 go test ./internal/identity -run TestSDKTrustedUserFixture`
|
|
92
|
+
rewrites the gateway-signed token the SDK verifies. All values in them are test-only.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
CC-BY-NC-4.0, as the rest of the repository.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type JWK } from "jose";
|
|
2
|
+
/** What an assertion is presented for. Each purpose has its own JWS `typ`, so one cannot replay as another. */
|
|
3
|
+
export type AssertionPurpose = "session" | "handoff" | "lifecycle";
|
|
4
|
+
/** JWS `typ` header of each purpose, as the gateway requires it. */
|
|
5
|
+
export declare const ASSERTION_TYPES: Readonly<Record<AssertionPurpose, string>>;
|
|
6
|
+
/** Asymmetric algorithms a `jwks` application may sign with. */
|
|
7
|
+
export type AsymmetricAlgorithm = "RS256" | "ES256" | "EdDSA";
|
|
8
|
+
/** Longest assertion lifetime the gateway accepts (exp - iat). */
|
|
9
|
+
export declare const MAX_ASSERTION_TTL_SECONDS = 300;
|
|
10
|
+
/** Default assertion lifetime. Assertions are single use; a short life is enough. */
|
|
11
|
+
export declare const DEFAULT_ASSERTION_TTL_SECONDS = 60;
|
|
12
|
+
/** Shortest HMAC assertion secret the gateway uses. */
|
|
13
|
+
export declare const MIN_SECRET_LENGTH = 32;
|
|
14
|
+
/**
|
|
15
|
+
* The partner application's signing identity. Create it once from your configuration and pass it
|
|
16
|
+
* to every call. Exactly one of `secret` (hmac application) or `privateKeyJwk` (jwks application)
|
|
17
|
+
* is set.
|
|
18
|
+
*/
|
|
19
|
+
export interface AssertionSigner {
|
|
20
|
+
/** The application id Dewee issued (`dewee identity setup` prints it). */
|
|
21
|
+
applicationId: string;
|
|
22
|
+
/** The issuer configured on the application, exactly. */
|
|
23
|
+
issuer: string;
|
|
24
|
+
/** The application's assertion secret (hmac mode). Keep it on your server. */
|
|
25
|
+
secret?: string;
|
|
26
|
+
/** A private JWK whose public half is in the application's `jwks_url` (jwks mode). Needs `kid`. */
|
|
27
|
+
privateKeyJwk?: JWK;
|
|
28
|
+
/** Algorithm of `privateKeyJwk`; defaults to the JWK's `alg`. */
|
|
29
|
+
algorithm?: AsymmetricAlgorithm;
|
|
30
|
+
}
|
|
31
|
+
export interface CreateAssertionOptions extends AssertionSigner {
|
|
32
|
+
purpose: AssertionPurpose;
|
|
33
|
+
/** Your stable, opaque user id. Never an email address: emails get reassigned. */
|
|
34
|
+
subject: string;
|
|
35
|
+
/** Required for `handoff`: the `dewee_txn` value Dewee passed to your connect start URL. */
|
|
36
|
+
nonce?: string;
|
|
37
|
+
/** Lifecycle only: the event the assertion reports. Leave it out for session revoke and logout. */
|
|
38
|
+
event?: LifecycleEvent;
|
|
39
|
+
/** Lifecycle only, with `scopes_changed`: the user's new scopes. */
|
|
40
|
+
scopes?: string[];
|
|
41
|
+
/** Lifetime in seconds, 1 to 300 (default 60). */
|
|
42
|
+
ttlSeconds?: number;
|
|
43
|
+
/** Clock override, for tests. */
|
|
44
|
+
now?: Date;
|
|
45
|
+
/** `jti` override, for tests. Every assertion must have a fresh one. */
|
|
46
|
+
jti?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Principal lifecycle events a partner reports. */
|
|
49
|
+
export type LifecycleEvent = "suspend" | "reactivate" | "delete" | "scopes_changed";
|
|
50
|
+
/** The audience Dewee issued an application: `dewee:<application id>`. */
|
|
51
|
+
export declare function applicationAudience(applicationId: string): string;
|
|
52
|
+
/** Validates a UUID argument (lower-cased) and refuses the nil UUID. */
|
|
53
|
+
export declare function requireUuid(value: unknown, name: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Validates a subject the way the gateway does, a little stricter: non-empty, at most 255 bytes,
|
|
56
|
+
* and no `@`, so an email address can never key a user.
|
|
57
|
+
*/
|
|
58
|
+
export declare function requireSubject(subject: unknown): string;
|
|
59
|
+
/**
|
|
60
|
+
* Signs a partner assertion: a compact JWS with `iss`, `aud` (`dewee:<application id>`), `sub`,
|
|
61
|
+
* `iat`, `exp` and a random `jti`, plus `nonce` for a handoff and `event`/`scopes` for a lifecycle
|
|
62
|
+
* report. Each assertion is accepted once.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createAssertion(opts: CreateAssertionOptions): Promise<string>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { importJWK, SignJWT } from "jose";
|
|
2
|
+
import { invalidArgument } from "./errors.js";
|
|
3
|
+
import { assertServer } from "./transport.js";
|
|
4
|
+
/** JWS `typ` header of each purpose, as the gateway requires it. */
|
|
5
|
+
export const ASSERTION_TYPES = {
|
|
6
|
+
session: "dewee-session+jwt",
|
|
7
|
+
handoff: "dewee-handoff+jwt",
|
|
8
|
+
lifecycle: "dewee-lifecycle+jwt",
|
|
9
|
+
};
|
|
10
|
+
const ASYMMETRIC_ALGORITHMS = new Set(["RS256", "ES256", "EdDSA"]);
|
|
11
|
+
/** Longest assertion lifetime the gateway accepts (exp - iat). */
|
|
12
|
+
export const MAX_ASSERTION_TTL_SECONDS = 300;
|
|
13
|
+
/** Default assertion lifetime. Assertions are single use; a short life is enough. */
|
|
14
|
+
export const DEFAULT_ASSERTION_TTL_SECONDS = 60;
|
|
15
|
+
/** Shortest HMAC assertion secret the gateway uses. */
|
|
16
|
+
export const MIN_SECRET_LENGTH = 32;
|
|
17
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
19
|
+
const LIFECYCLE_EVENTS = new Set(["suspend", "reactivate", "delete", "scopes_changed"]);
|
|
20
|
+
/** The audience Dewee issued an application: `dewee:<application id>`. */
|
|
21
|
+
export function applicationAudience(applicationId) {
|
|
22
|
+
return `dewee:${applicationId.toLowerCase()}`;
|
|
23
|
+
}
|
|
24
|
+
/** Validates a UUID argument (lower-cased) and refuses the nil UUID. */
|
|
25
|
+
export function requireUuid(value, name) {
|
|
26
|
+
if (typeof value !== "string" || !UUID_PATTERN.test(value) || value === NIL_UUID) {
|
|
27
|
+
throw invalidArgument(`${name} must be a UUID`);
|
|
28
|
+
}
|
|
29
|
+
return value.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validates a subject the way the gateway does, a little stricter: non-empty, at most 255 bytes,
|
|
33
|
+
* and no `@`, so an email address can never key a user.
|
|
34
|
+
*/
|
|
35
|
+
export function requireSubject(subject) {
|
|
36
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
37
|
+
throw invalidArgument("subject is required: your stable user id");
|
|
38
|
+
}
|
|
39
|
+
if (new TextEncoder().encode(subject).length > 255) {
|
|
40
|
+
throw invalidArgument("subject must be at most 255 bytes");
|
|
41
|
+
}
|
|
42
|
+
if (subject.includes("@")) {
|
|
43
|
+
throw invalidArgument("subject must be your stable opaque user id, not an email address");
|
|
44
|
+
}
|
|
45
|
+
return subject;
|
|
46
|
+
}
|
|
47
|
+
function requireIssuer(issuer) {
|
|
48
|
+
if (typeof issuer !== "string" || issuer.trim() === "" || issuer.length > 255 || /\s/.test(issuer)) {
|
|
49
|
+
throw invalidArgument("issuer is required: the issuer configured on the application, exactly");
|
|
50
|
+
}
|
|
51
|
+
return issuer;
|
|
52
|
+
}
|
|
53
|
+
/** Validates a signer and returns its key and algorithm. The key never leaves this module. */
|
|
54
|
+
async function signingKey(signer) {
|
|
55
|
+
const hasSecret = signer.secret !== undefined;
|
|
56
|
+
const hasJwk = signer.privateKeyJwk !== undefined;
|
|
57
|
+
if (hasSecret === hasJwk) {
|
|
58
|
+
throw invalidArgument("set exactly one of secret (hmac application) or privateKeyJwk (jwks application)");
|
|
59
|
+
}
|
|
60
|
+
if (hasSecret) {
|
|
61
|
+
if (typeof signer.secret !== "string" || new TextEncoder().encode(signer.secret).length < MIN_SECRET_LENGTH) {
|
|
62
|
+
throw invalidArgument(`secret must be the application's assertion secret (at least ${MIN_SECRET_LENGTH} bytes)`);
|
|
63
|
+
}
|
|
64
|
+
// The gateway uses the secret's UTF-8 bytes as the HMAC key, exactly as issued (not decoded).
|
|
65
|
+
return { key: new TextEncoder().encode(signer.secret), alg: "HS256" };
|
|
66
|
+
}
|
|
67
|
+
const jwk = signer.privateKeyJwk;
|
|
68
|
+
if (jwk === null || typeof jwk !== "object" || typeof jwk.d !== "string") {
|
|
69
|
+
throw invalidArgument("privateKeyJwk must be a private JWK");
|
|
70
|
+
}
|
|
71
|
+
if (typeof jwk.kid !== "string" || jwk.kid === "") {
|
|
72
|
+
throw invalidArgument("privateKeyJwk needs a kid that matches the key in your jwks_url");
|
|
73
|
+
}
|
|
74
|
+
const alg = signer.algorithm ?? jwk.alg;
|
|
75
|
+
if (typeof alg !== "string" || !ASYMMETRIC_ALGORITHMS.has(alg)) {
|
|
76
|
+
throw invalidArgument("algorithm must be RS256, ES256 or EdDSA");
|
|
77
|
+
}
|
|
78
|
+
let key;
|
|
79
|
+
try {
|
|
80
|
+
key = await importJWK(jwk, alg);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw invalidArgument(`privateKeyJwk is not a usable ${alg} private key`);
|
|
84
|
+
}
|
|
85
|
+
return { key, alg, kid: jwk.kid };
|
|
86
|
+
}
|
|
87
|
+
/** A random assertion id. */
|
|
88
|
+
function newJti() {
|
|
89
|
+
return globalThis.crypto.randomUUID();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Signs a partner assertion: a compact JWS with `iss`, `aud` (`dewee:<application id>`), `sub`,
|
|
93
|
+
* `iat`, `exp` and a random `jti`, plus `nonce` for a handoff and `event`/`scopes` for a lifecycle
|
|
94
|
+
* report. Each assertion is accepted once.
|
|
95
|
+
*/
|
|
96
|
+
export async function createAssertion(opts) {
|
|
97
|
+
assertServer();
|
|
98
|
+
if (opts === null || typeof opts !== "object")
|
|
99
|
+
throw invalidArgument("options are required");
|
|
100
|
+
const typ = ASSERTION_TYPES[opts.purpose];
|
|
101
|
+
if (typ === undefined)
|
|
102
|
+
throw invalidArgument("purpose must be session, handoff or lifecycle");
|
|
103
|
+
const applicationId = requireUuid(opts.applicationId, "applicationId");
|
|
104
|
+
const issuer = requireIssuer(opts.issuer);
|
|
105
|
+
const subject = requireSubject(opts.subject);
|
|
106
|
+
const ttl = opts.ttlSeconds ?? DEFAULT_ASSERTION_TTL_SECONDS;
|
|
107
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > MAX_ASSERTION_TTL_SECONDS) {
|
|
108
|
+
throw invalidArgument(`ttlSeconds must be an integer from 1 to ${MAX_ASSERTION_TTL_SECONDS}`);
|
|
109
|
+
}
|
|
110
|
+
const claims = {};
|
|
111
|
+
if (opts.purpose === "handoff") {
|
|
112
|
+
if (typeof opts.nonce !== "string" || opts.nonce === "" || opts.nonce.length > 512) {
|
|
113
|
+
throw invalidArgument("a handoff assertion needs nonce: the dewee_txn value of the connect request");
|
|
114
|
+
}
|
|
115
|
+
claims.nonce = opts.nonce;
|
|
116
|
+
}
|
|
117
|
+
else if (opts.nonce !== undefined) {
|
|
118
|
+
throw invalidArgument("nonce is for handoff assertions only");
|
|
119
|
+
}
|
|
120
|
+
if (opts.purpose === "lifecycle") {
|
|
121
|
+
// A lifecycle assertion without an event authorizes session revoke and logout only: replayed
|
|
122
|
+
// at the lifecycle route it is refused, so it can never suspend or reactivate anyone.
|
|
123
|
+
if (opts.event !== undefined && !LIFECYCLE_EVENTS.has(opts.event)) {
|
|
124
|
+
throw invalidArgument("event must be suspend, reactivate, delete or scopes_changed");
|
|
125
|
+
}
|
|
126
|
+
if (opts.event !== undefined)
|
|
127
|
+
claims.event = opts.event;
|
|
128
|
+
if (opts.event === "scopes_changed") {
|
|
129
|
+
if (!Array.isArray(opts.scopes) || !opts.scopes.every((s) => typeof s === "string")) {
|
|
130
|
+
throw invalidArgument("scopes_changed needs scopes: an array of strings");
|
|
131
|
+
}
|
|
132
|
+
claims.scopes = [...opts.scopes];
|
|
133
|
+
}
|
|
134
|
+
else if (opts.scopes !== undefined) {
|
|
135
|
+
throw invalidArgument("scopes go with the scopes_changed event only");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (opts.event !== undefined || opts.scopes !== undefined) {
|
|
139
|
+
throw invalidArgument("event and scopes are for lifecycle assertions only");
|
|
140
|
+
}
|
|
141
|
+
const jti = opts.jti ?? newJti();
|
|
142
|
+
if (typeof jti !== "string" || jti === "" || jti.length > 255)
|
|
143
|
+
throw invalidArgument("jti must be a non-empty string");
|
|
144
|
+
const { key, alg, kid } = await signingKey(opts);
|
|
145
|
+
const iat = Math.floor((opts.now ?? new Date()).getTime() / 1000);
|
|
146
|
+
return new SignJWT(claims)
|
|
147
|
+
.setProtectedHeader(kid === undefined ? { alg, typ } : { alg, typ, kid })
|
|
148
|
+
.setIssuer(issuer)
|
|
149
|
+
.setAudience(applicationAudience(applicationId))
|
|
150
|
+
.setSubject(subject)
|
|
151
|
+
.setIssuedAt(iat)
|
|
152
|
+
.setExpirationTime(iat + ttl)
|
|
153
|
+
.setJti(jti)
|
|
154
|
+
.sign(key);
|
|
155
|
+
}
|
package/dist/browser.js
ADDED
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors of the Dewee identity SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every failure the SDK reports is a {@link DeweeIdentityError}. Its message never carries a
|
|
5
|
+
* secret, an assertion, a session token or a subject: only the code, the HTTP status and the fixed
|
|
6
|
+
* sentence the gateway sent.
|
|
7
|
+
*/
|
|
8
|
+
/** The seven identity error codes of the gateway (`IdentityError.error.code` in the OpenAPI spec). */
|
|
9
|
+
export declare const IDENTITY_ERROR_CODES: readonly ["IDENTITY_REQUIRED", "IDENTITY_LINK_REQUIRED", "IDENTITY_CONFLICT", "LINK_EXPIRED", "LINK_REVOKED", "PERMISSION_DENIED", "SOURCE_UNAVAILABLE"];
|
|
10
|
+
export type IdentityErrorCode = (typeof IDENTITY_ERROR_CODES)[number];
|
|
11
|
+
/** Generic gateway error codes the identity routes also answer with. */
|
|
12
|
+
export declare const GATEWAY_ERROR_CODES: readonly ["INVALID_REQUEST", "NOT_FOUND", "FAILED_PRECONDITION", "ALREADY_EXISTS", "IDEMPOTENCY_KEY_INVALID", "IDEMPOTENCY_KEY_REUSED", "REQUEST_TOO_LARGE", "RESOURCE_EXHAUSTED", "UNAUTHORIZED", "UNAVAILABLE", "INTERNAL"];
|
|
13
|
+
export type GatewayErrorCode = (typeof GATEWAY_ERROR_CODES)[number];
|
|
14
|
+
/** Codes the SDK raises itself, before or instead of a gateway answer. */
|
|
15
|
+
export declare const SDK_ERROR_CODES: readonly ["INVALID_ARGUMENT", "SERVER_ONLY", "TRANSPORT_ERROR", "INVALID_RESPONSE"];
|
|
16
|
+
export type SdkErrorCode = (typeof SDK_ERROR_CODES)[number];
|
|
17
|
+
export type DeweeIdentityErrorCode = IdentityErrorCode | GatewayErrorCode | SdkErrorCode;
|
|
18
|
+
/** Remediation actions of the gateway (`IdentityError.error.remediation.action`). */
|
|
19
|
+
export declare const REMEDIATION_ACTIONS: readonly ["send_connect_link", "ask_user_to_dm", "retry_later", "contact_admin", "reauthenticate"];
|
|
20
|
+
export type RemediationAction = (typeof REMEDIATION_ACTIONS)[number];
|
|
21
|
+
/** Who acts on a remediation (`IdentityError.error.remediation.audience`). */
|
|
22
|
+
export declare const REMEDIATION_AUDIENCES: readonly ["end_user", "operator", "integrator"];
|
|
23
|
+
export type RemediationAudience = (typeof REMEDIATION_AUDIENCES)[number];
|
|
24
|
+
export interface Remediation {
|
|
25
|
+
action: RemediationAction;
|
|
26
|
+
audience: RemediationAudience;
|
|
27
|
+
}
|
|
28
|
+
/** Why {@link verifyTrustedUser} refused a token. Bounded, safe to log. */
|
|
29
|
+
export type TrustedUserRefusal = "missing" | "malformed" | "algorithm" | "type" | "signature" | "audience" | "issuer" | "tenant" | "application" | "not_yet_valid" | "lifetime" | "expired" | "subject" | "jti" | "replay";
|
|
30
|
+
/** What an integrator does about each identity code. English, for logs and integrator UIs. */
|
|
31
|
+
export declare const IDENTITY_ERROR_HINTS: Readonly<Record<IdentityErrorCode, string>>;
|
|
32
|
+
export declare function isIdentityErrorCode(code: unknown): code is IdentityErrorCode;
|
|
33
|
+
export interface DeweeIdentityErrorInit {
|
|
34
|
+
code: DeweeIdentityErrorCode;
|
|
35
|
+
message: string;
|
|
36
|
+
/** HTTP status of the gateway answer; 0 when no answer was received. */
|
|
37
|
+
status?: number;
|
|
38
|
+
remediation?: Remediation;
|
|
39
|
+
/** Seconds to wait before retrying (429 `Retry-After`). */
|
|
40
|
+
retryAfterSeconds?: number;
|
|
41
|
+
/** Set by {@link verifyTrustedUser} only. */
|
|
42
|
+
reason?: TrustedUserRefusal;
|
|
43
|
+
cause?: unknown;
|
|
44
|
+
}
|
|
45
|
+
export declare class DeweeIdentityError extends Error {
|
|
46
|
+
readonly code: DeweeIdentityErrorCode;
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly remediation: Remediation | undefined;
|
|
49
|
+
readonly retryAfterSeconds: number | undefined;
|
|
50
|
+
readonly reason: TrustedUserRefusal | undefined;
|
|
51
|
+
constructor(init: DeweeIdentityErrorInit);
|
|
52
|
+
/** True for one of the seven identity codes. */
|
|
53
|
+
get isIdentityError(): boolean;
|
|
54
|
+
/** True when the same request may succeed later without a change (429, 503, transport). */
|
|
55
|
+
get retryable(): boolean;
|
|
56
|
+
/** Never serialize anything but the safe fields. */
|
|
57
|
+
toJSON(): Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
export declare function invalidArgument(message: string): DeweeIdentityError;
|
|
60
|
+
/**
|
|
61
|
+
* Builds the typed error of a non-2xx gateway answer. The body is either the identity error
|
|
62
|
+
* envelope `{error: {code, message, type: "identity_error", remediation}}` or the generic one
|
|
63
|
+
* `{error: {code, message}}`. An unknown code keeps the status-derived code, so a caller can always
|
|
64
|
+
* switch on it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function errorFromResponse(status: number, body: unknown, retryAfter: string | null): DeweeIdentityError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors of the Dewee identity SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every failure the SDK reports is a {@link DeweeIdentityError}. Its message never carries a
|
|
5
|
+
* secret, an assertion, a session token or a subject: only the code, the HTTP status and the fixed
|
|
6
|
+
* sentence the gateway sent.
|
|
7
|
+
*/
|
|
8
|
+
/** The seven identity error codes of the gateway (`IdentityError.error.code` in the OpenAPI spec). */
|
|
9
|
+
export const IDENTITY_ERROR_CODES = [
|
|
10
|
+
"IDENTITY_REQUIRED",
|
|
11
|
+
"IDENTITY_LINK_REQUIRED",
|
|
12
|
+
"IDENTITY_CONFLICT",
|
|
13
|
+
"LINK_EXPIRED",
|
|
14
|
+
"LINK_REVOKED",
|
|
15
|
+
"PERMISSION_DENIED",
|
|
16
|
+
"SOURCE_UNAVAILABLE",
|
|
17
|
+
];
|
|
18
|
+
/** Generic gateway error codes the identity routes also answer with. */
|
|
19
|
+
export const GATEWAY_ERROR_CODES = [
|
|
20
|
+
"INVALID_REQUEST",
|
|
21
|
+
"NOT_FOUND",
|
|
22
|
+
"FAILED_PRECONDITION",
|
|
23
|
+
"ALREADY_EXISTS",
|
|
24
|
+
"IDEMPOTENCY_KEY_INVALID",
|
|
25
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
26
|
+
"REQUEST_TOO_LARGE",
|
|
27
|
+
"RESOURCE_EXHAUSTED",
|
|
28
|
+
"UNAUTHORIZED",
|
|
29
|
+
"UNAVAILABLE",
|
|
30
|
+
"INTERNAL",
|
|
31
|
+
];
|
|
32
|
+
/** Codes the SDK raises itself, before or instead of a gateway answer. */
|
|
33
|
+
export const SDK_ERROR_CODES = [
|
|
34
|
+
/** A caller argument is missing or invalid; nothing was sent. */
|
|
35
|
+
"INVALID_ARGUMENT",
|
|
36
|
+
/** The SDK was loaded in a browser. It holds partner secrets and must run on a server. */
|
|
37
|
+
"SERVER_ONLY",
|
|
38
|
+
/** The gateway could not be reached (DNS, TLS, connection reset, timeout). */
|
|
39
|
+
"TRANSPORT_ERROR",
|
|
40
|
+
/** The gateway answered with a body the SDK does not understand. */
|
|
41
|
+
"INVALID_RESPONSE",
|
|
42
|
+
];
|
|
43
|
+
/** Remediation actions of the gateway (`IdentityError.error.remediation.action`). */
|
|
44
|
+
export const REMEDIATION_ACTIONS = [
|
|
45
|
+
"send_connect_link",
|
|
46
|
+
"ask_user_to_dm",
|
|
47
|
+
"retry_later",
|
|
48
|
+
"contact_admin",
|
|
49
|
+
"reauthenticate",
|
|
50
|
+
];
|
|
51
|
+
/** Who acts on a remediation (`IdentityError.error.remediation.audience`). */
|
|
52
|
+
export const REMEDIATION_AUDIENCES = ["end_user", "operator", "integrator"];
|
|
53
|
+
/** What an integrator does about each identity code. English, for logs and integrator UIs. */
|
|
54
|
+
export const IDENTITY_ERROR_HINTS = {
|
|
55
|
+
IDENTITY_REQUIRED: "The proof was not accepted. Check the application id, issuer, secret, clock and that each assertion is used once.",
|
|
56
|
+
IDENTITY_LINK_REQUIRED: "The chat account is not linked yet. Dewee sends the user a Connect link.",
|
|
57
|
+
IDENTITY_CONFLICT: "The chat account is linked to another user. Nothing changed; unlink it there first.",
|
|
58
|
+
LINK_EXPIRED: "The link or session expired. Start again.",
|
|
59
|
+
LINK_REVOKED: "The user's access was revoked or suspended. Sign the user in again.",
|
|
60
|
+
PERMISSION_DENIED: "The user is verified but not allowed to do this.",
|
|
61
|
+
SOURCE_UNAVAILABLE: "Dewee could not check the identity, so it refused. Retry later.",
|
|
62
|
+
};
|
|
63
|
+
const IDENTITY_CODE_SET = new Set(IDENTITY_ERROR_CODES);
|
|
64
|
+
const GATEWAY_CODE_SET = new Set(GATEWAY_ERROR_CODES);
|
|
65
|
+
const ACTION_SET = new Set(REMEDIATION_ACTIONS);
|
|
66
|
+
const AUDIENCE_SET = new Set(REMEDIATION_AUDIENCES);
|
|
67
|
+
export function isIdentityErrorCode(code) {
|
|
68
|
+
return typeof code === "string" && IDENTITY_CODE_SET.has(code);
|
|
69
|
+
}
|
|
70
|
+
export class DeweeIdentityError extends Error {
|
|
71
|
+
code;
|
|
72
|
+
status;
|
|
73
|
+
remediation;
|
|
74
|
+
retryAfterSeconds;
|
|
75
|
+
reason;
|
|
76
|
+
constructor(init) {
|
|
77
|
+
super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
|
|
78
|
+
this.name = "DeweeIdentityError";
|
|
79
|
+
this.code = init.code;
|
|
80
|
+
this.status = init.status ?? 0;
|
|
81
|
+
this.remediation = init.remediation;
|
|
82
|
+
this.retryAfterSeconds = init.retryAfterSeconds;
|
|
83
|
+
this.reason = init.reason;
|
|
84
|
+
}
|
|
85
|
+
/** True for one of the seven identity codes. */
|
|
86
|
+
get isIdentityError() {
|
|
87
|
+
return isIdentityErrorCode(this.code);
|
|
88
|
+
}
|
|
89
|
+
/** True when the same request may succeed later without a change (429, 503, transport). */
|
|
90
|
+
get retryable() {
|
|
91
|
+
return (this.code === "RESOURCE_EXHAUSTED" ||
|
|
92
|
+
this.code === "SOURCE_UNAVAILABLE" ||
|
|
93
|
+
this.code === "UNAVAILABLE" ||
|
|
94
|
+
this.code === "TRANSPORT_ERROR");
|
|
95
|
+
}
|
|
96
|
+
/** Never serialize anything but the safe fields. */
|
|
97
|
+
toJSON() {
|
|
98
|
+
return {
|
|
99
|
+
name: this.name,
|
|
100
|
+
code: this.code,
|
|
101
|
+
status: this.status,
|
|
102
|
+
message: this.message,
|
|
103
|
+
remediation: this.remediation,
|
|
104
|
+
retryAfterSeconds: this.retryAfterSeconds,
|
|
105
|
+
reason: this.reason,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function invalidArgument(message) {
|
|
110
|
+
return new DeweeIdentityError({ code: "INVALID_ARGUMENT", message });
|
|
111
|
+
}
|
|
112
|
+
function parseRemediation(raw) {
|
|
113
|
+
if (raw === null || typeof raw !== "object")
|
|
114
|
+
return undefined;
|
|
115
|
+
const { action, audience } = raw;
|
|
116
|
+
if (typeof action === "string" && ACTION_SET.has(action) && typeof audience === "string" && AUDIENCE_SET.has(audience)) {
|
|
117
|
+
return { action: action, audience: audience };
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function statusFallbackCode(status) {
|
|
122
|
+
switch (status) {
|
|
123
|
+
case 400:
|
|
124
|
+
return "INVALID_REQUEST";
|
|
125
|
+
case 401:
|
|
126
|
+
return "IDENTITY_REQUIRED";
|
|
127
|
+
case 403:
|
|
128
|
+
return "PERMISSION_DENIED";
|
|
129
|
+
case 404:
|
|
130
|
+
return "NOT_FOUND";
|
|
131
|
+
case 409:
|
|
132
|
+
return "FAILED_PRECONDITION";
|
|
133
|
+
case 413:
|
|
134
|
+
return "REQUEST_TOO_LARGE";
|
|
135
|
+
case 429:
|
|
136
|
+
return "RESOURCE_EXHAUSTED";
|
|
137
|
+
case 503:
|
|
138
|
+
return "UNAVAILABLE";
|
|
139
|
+
default:
|
|
140
|
+
return "INTERNAL";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Builds the typed error of a non-2xx gateway answer. The body is either the identity error
|
|
145
|
+
* envelope `{error: {code, message, type: "identity_error", remediation}}` or the generic one
|
|
146
|
+
* `{error: {code, message}}`. An unknown code keeps the status-derived code, so a caller can always
|
|
147
|
+
* switch on it.
|
|
148
|
+
*/
|
|
149
|
+
export function errorFromResponse(status, body, retryAfter) {
|
|
150
|
+
let code = statusFallbackCode(status);
|
|
151
|
+
let message = `Dewee answered HTTP ${status}`;
|
|
152
|
+
let remediation;
|
|
153
|
+
const envelope = body !== null && typeof body === "object" ? body.error : undefined;
|
|
154
|
+
if (envelope !== null && typeof envelope === "object") {
|
|
155
|
+
const e = envelope;
|
|
156
|
+
if (typeof e.code === "string" && (IDENTITY_CODE_SET.has(e.code) || GATEWAY_CODE_SET.has(e.code))) {
|
|
157
|
+
code = e.code;
|
|
158
|
+
}
|
|
159
|
+
if (typeof e.message === "string" && e.message.trim() !== "") {
|
|
160
|
+
message = `${code}: ${e.message.slice(0, 300)}`;
|
|
161
|
+
}
|
|
162
|
+
remediation = parseRemediation(e.remediation);
|
|
163
|
+
}
|
|
164
|
+
if (message.startsWith("Dewee answered")) {
|
|
165
|
+
message = `${code}: ${message}`;
|
|
166
|
+
}
|
|
167
|
+
let retryAfterSeconds;
|
|
168
|
+
if (retryAfter !== null) {
|
|
169
|
+
const n = Number.parseInt(retryAfter, 10);
|
|
170
|
+
if (Number.isFinite(n) && n >= 0)
|
|
171
|
+
retryAfterSeconds = n;
|
|
172
|
+
}
|
|
173
|
+
return new DeweeIdentityError({ code, message, status, remediation, retryAfterSeconds });
|
|
174
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type AssertionSigner } from "./assertion.js";
|
|
2
|
+
import { type GatewayOptions } from "./transport.js";
|
|
3
|
+
/** Authorization scheme of partner server calls. */
|
|
4
|
+
export declare const ASSERTION_AUTH_SCHEME = "Dewee-Assertion";
|
|
5
|
+
/** Header that carries an identity session token on HTTP calls. */
|
|
6
|
+
export declare const IDENTITY_SESSION_HEADER = "X-Dewee-Identity-Session";
|
|
7
|
+
/** Bounds of an identity session lifetime. */
|
|
8
|
+
export declare const MIN_SESSION_TTL_SECONDS = 60;
|
|
9
|
+
export declare const MAX_SESSION_TTL_SECONDS = 43200;
|
|
10
|
+
interface ExchangeCommon extends GatewayOptions {
|
|
11
|
+
/** The agent the session is for: its id or its key, in the application's tenant. */
|
|
12
|
+
agent: string;
|
|
13
|
+
/** Session lifetime, 60 to 43200 seconds (gateway default 3600). */
|
|
14
|
+
ttlSeconds?: number;
|
|
15
|
+
}
|
|
16
|
+
/** Exchange a ready assertion (`purpose: "session"`). */
|
|
17
|
+
export interface ExchangeWithAssertion extends ExchangeCommon {
|
|
18
|
+
assertion: string;
|
|
19
|
+
/**
|
|
20
|
+
* Set for an oidc application: `assertion` is then the user's OIDC ID token, and this is the
|
|
21
|
+
* Dewee application id it is for.
|
|
22
|
+
*/
|
|
23
|
+
oidcApplicationId?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Sign a session assertion for `subject` and exchange it in one call. */
|
|
26
|
+
export interface ExchangeWithSigner extends ExchangeCommon {
|
|
27
|
+
signer: AssertionSigner;
|
|
28
|
+
subject: string;
|
|
29
|
+
}
|
|
30
|
+
export type ExchangeSessionOptions = ExchangeWithAssertion | ExchangeWithSigner;
|
|
31
|
+
/** An identity session. Hand `sessionToken` to the widget; never log it. */
|
|
32
|
+
export interface IdentitySession {
|
|
33
|
+
sessionToken: string;
|
|
34
|
+
/** Header the token travels in on HTTP calls (`X-Dewee-Identity-Session`). */
|
|
35
|
+
header: string;
|
|
36
|
+
sessionId: string;
|
|
37
|
+
principalId: string;
|
|
38
|
+
applicationId: string;
|
|
39
|
+
agentId: string;
|
|
40
|
+
expiresAt: Date;
|
|
41
|
+
testMode: boolean;
|
|
42
|
+
/** Present for a test-mode application: show it wherever this session's data appears. */
|
|
43
|
+
testModeBanner?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Exchanges a partner proof of one user for a short-lived identity session bound to one agent
|
|
47
|
+
* (`POST /v1/identity/sessions/exchange`). The tenant and application come from the verified
|
|
48
|
+
* assertion; nothing in the request names a user, tenant or role.
|
|
49
|
+
*/
|
|
50
|
+
export declare function exchangeSession(opts: ExchangeSessionOptions): Promise<IdentitySession>;
|
|
51
|
+
export {};
|