@tiertwo/sdk 0.1.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/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/extract.d.ts +8 -0
- package/dist/extract.js +50 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/jti.d.ts +20 -0
- package/dist/jti.js +43 -0
- package/dist/jwks.d.ts +4 -0
- package/dist/jwks.js +19 -0
- package/dist/types.d.ts +72 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +16 -0
- package/dist/verify.js +135 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tier Two
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @tiertwo/sdk
|
|
2
|
+
|
|
3
|
+
Verify **Tier Two** verified-human signup tokens on your server in one call.
|
|
4
|
+
|
|
5
|
+
When an AI agent (Claude Code, Codex, …) creates an account on your site as a real,
|
|
6
|
+
[Tier Two](https://trytiertwo.com)-verified human, it presents a short-lived
|
|
7
|
+
`Authorization: Bearer <jwt>`. This SDK verifies that token **offline** against Tier
|
|
8
|
+
Two's published JWKS — no API key, no network call to us on the hot path beyond
|
|
9
|
+
fetching (and caching) the public keys.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @tiertwo/sdk # or pnpm add / yarn add
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { verifyHumanSignup } from "@tiertwo/sdk";
|
|
19
|
+
|
|
20
|
+
// Express / Next route handler / any server with a Request-like object.
|
|
21
|
+
app.post("/signup", async (req, res) => {
|
|
22
|
+
const result = await verifyHumanSignup(req, { domain: "acme.com" });
|
|
23
|
+
|
|
24
|
+
if (!result.valid) {
|
|
25
|
+
// Fail closed — never create the account on a failed check.
|
|
26
|
+
return res.status(403).json({ ok: false, reason: result.reason });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A verified human authorized this signup for acme.com → create the account.
|
|
30
|
+
res.status(201).json({
|
|
31
|
+
sub: result.sub, // stable, pairwise id for (this human, your domain)
|
|
32
|
+
country: result.country, // ISO-3166 alpha-2, or null
|
|
33
|
+
age_over_18: result.age_over_18,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`req` may be a Web `Request`, a Node `IncomingMessage`, or a raw token string — the SDK
|
|
39
|
+
extracts the bearer token for you.
|
|
40
|
+
|
|
41
|
+
## What gets verified
|
|
42
|
+
|
|
43
|
+
`verifyHumanSignup` checks, against the issuer's JWKS:
|
|
44
|
+
|
|
45
|
+
- **signature** (EdDSA only — `alg:none` and HS-key-confusion are rejected),
|
|
46
|
+
- **`iss`** equals your configured issuer (defaults to `https://trytiertwo.com`),
|
|
47
|
+
- **`aud`** equals your `domain`,
|
|
48
|
+
- **`action`** is in the closed enum (MVP: `create_account`),
|
|
49
|
+
- **`exp` / `nbf`** within a ±30s clock-skew tolerance,
|
|
50
|
+
- **`verified_human === true`**.
|
|
51
|
+
|
|
52
|
+
## Result shape
|
|
53
|
+
|
|
54
|
+
`verifyHumanSignup` returns a discriminated union — narrow on `valid`. It **never
|
|
55
|
+
throws**; every error path returns `{ valid: false, reason }` so you always fail closed.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
type VerifyResult =
|
|
59
|
+
| {
|
|
60
|
+
valid: true;
|
|
61
|
+
verified: true;
|
|
62
|
+
country: string | null; // ISO-3166 alpha-2
|
|
63
|
+
age_over_18: boolean;
|
|
64
|
+
sub: string; // pairwise/directed subject — stable per (user, your domain)
|
|
65
|
+
deviceId: string; // the device that produced the request
|
|
66
|
+
jti: string; // unique token id — dedupe it (see below)
|
|
67
|
+
action: "create_account";
|
|
68
|
+
}
|
|
69
|
+
| { valid: false; reason: FailureReason };
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`reason` is one of: `no_token`, `no_domain`, `malformed`, `bad_signature`, `expired`,
|
|
73
|
+
`not_yet_valid`, `wrong_issuer`, `wrong_audience`, `wrong_action`, `not_verified`,
|
|
74
|
+
`jwks_unreachable`. A JWKS fetch failure surfaces as `jwks_unreachable` — still a
|
|
75
|
+
failure, so you stay closed.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
Options can be passed inline or sourced from the environment so the call is argument-free:
|
|
80
|
+
|
|
81
|
+
| Option | Env var | Default |
|
|
82
|
+
| ---------- | --------------------- | --------------------------- |
|
|
83
|
+
| `domain` | `TIERTWO_DOMAIN` | — (required) |
|
|
84
|
+
| `issuer` | `TIERTWO_ISSUER_URL` | `https://trytiertwo.com` |
|
|
85
|
+
| `jwksUrl` | — | `${issuer}/.well-known/jwks.json` |
|
|
86
|
+
| `jwks` | — | a memoized remote JWKS set |
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// With TIERTWO_DOMAIN set, the call needs no arguments:
|
|
90
|
+
const result = await verifyHumanSignup(req);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For local development against a self-hosted issuer, set
|
|
94
|
+
`TIERTWO_ISSUER_URL=http://localhost:3000`. The `jwks` option accepts a `jose` key
|
|
95
|
+
resolver (e.g. `createLocalJWKSet`) to verify fully offline in tests.
|
|
96
|
+
|
|
97
|
+
## Replay dedupe (recommended)
|
|
98
|
+
|
|
99
|
+
The token is single-purpose, domain+action-bound, and lives ≤5 minutes — but you
|
|
100
|
+
should also reject a `jti` you've already honored, so the same token can't create two
|
|
101
|
+
accounts inside its window. The SDK ships a small helper; wire it to your own store:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { verifyHumanSignup, hasSeenJti, MemoryJtiStore } from "@tiertwo/sdk";
|
|
105
|
+
|
|
106
|
+
// Use a shared store (Redis/DB) in production so dedupe holds across replicas.
|
|
107
|
+
// MemoryJtiStore is for single-process demos/tests.
|
|
108
|
+
const jtiStore = new MemoryJtiStore();
|
|
109
|
+
|
|
110
|
+
const result = await verifyHumanSignup(req, { domain: "acme.com" });
|
|
111
|
+
if (!result.valid) return reject(result.reason);
|
|
112
|
+
|
|
113
|
+
if (await hasSeenJti(result.jti, jtiStore)) {
|
|
114
|
+
return reject("replayed"); // already used this token
|
|
115
|
+
}
|
|
116
|
+
// ...create the account.
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`JtiStore` is just `{ has(jti), add(jti, expiresAtMs?) }` — back it with Redis `SET …
|
|
120
|
+
EX`, a unique DB column, etc. `hasSeenJti` records the `jti` with a default 5-minute
|
|
121
|
+
TTL (the token lifetime).
|
|
122
|
+
|
|
123
|
+
## Notes
|
|
124
|
+
|
|
125
|
+
- **Offline by design.** Verification trusts exactly one thing: the issuer JWKS.
|
|
126
|
+
There is no token-introspection call back to Tier Two.
|
|
127
|
+
- **Fail closed, always.** Treat any `{ valid: false }` — including
|
|
128
|
+
`jwks_unreachable` — as "do not create the account."
|
|
129
|
+
|
|
130
|
+
See [`docs/ARCHITECTURE.md`](https://github.com/Jordan-M/verified-human/blob/master/docs/ARCHITECTURE.md)
|
|
131
|
+
§12 for the full contract.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the JWT, or undefined if none is present.
|
|
3
|
+
* - A raw token string (already extracted by the caller) is passed through,
|
|
4
|
+
* with or without a `Bearer` prefix.
|
|
5
|
+
* - From a request object we read `Authorization: Bearer <jwt>` and require the
|
|
6
|
+
* scheme (a schemeless header value is non-standard → rejected).
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractBearer(input: unknown): string | undefined;
|
package/dist/extract.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pull the bearer token out of whatever the merchant's framework hands us:
|
|
2
|
+
// a Web `Request` (Next App Router, Hono, Deno), a Node `IncomingMessage`
|
|
3
|
+
// (Express/raw http — `req.headers.authorization`), or a raw token string.
|
|
4
|
+
// Framework-agnostic by design (ARCHITECTURE §12).
|
|
5
|
+
function readAuthorization(input) {
|
|
6
|
+
if (!input || typeof input !== "object")
|
|
7
|
+
return undefined;
|
|
8
|
+
const headers = input.headers;
|
|
9
|
+
if (!headers)
|
|
10
|
+
return undefined;
|
|
11
|
+
// Web `Headers` exposes a case-insensitive `.get()`.
|
|
12
|
+
if (typeof headers.get === "function") {
|
|
13
|
+
return headers.get("authorization") ?? undefined;
|
|
14
|
+
}
|
|
15
|
+
// Node lowercases header keys; tolerate arrays just in case.
|
|
16
|
+
const raw = headers["authorization"];
|
|
17
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns the JWT, or undefined if none is present.
|
|
21
|
+
* - A raw token string (already extracted by the caller) is passed through,
|
|
22
|
+
* with or without a `Bearer` prefix.
|
|
23
|
+
* - From a request object we read `Authorization: Bearer <jwt>` and require the
|
|
24
|
+
* scheme (a schemeless header value is non-standard → rejected).
|
|
25
|
+
*/
|
|
26
|
+
export function extractBearer(input) {
|
|
27
|
+
if (typeof input === "string") {
|
|
28
|
+
return parseAuthorization(input, /* allowBare */ true);
|
|
29
|
+
}
|
|
30
|
+
const header = readAuthorization(input);
|
|
31
|
+
if (header === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
return parseAuthorization(header, /* allowBare */ false);
|
|
34
|
+
}
|
|
35
|
+
function parseAuthorization(value, allowBare) {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return undefined;
|
|
39
|
+
const match = /^Bearer\s+(.+)$/i.exec(trimmed);
|
|
40
|
+
if (match)
|
|
41
|
+
return match[1].trim() || undefined;
|
|
42
|
+
// Begins with the scheme word but has no token after it → malformed.
|
|
43
|
+
if (/^Bearer\b/i.test(trimmed))
|
|
44
|
+
return undefined;
|
|
45
|
+
// A bare, schemeless token is accepted only from a raw string the caller
|
|
46
|
+
// passed directly, and only when it isn't itself a (wrong-scheme) header.
|
|
47
|
+
if (allowBare && !/\s/.test(trimmed))
|
|
48
|
+
return trimmed;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @tiertwo/sdk — the merchant/enforcer's one-verb verification of a Tier Two token.
|
|
2
|
+
// `verifyHumanAction` verifies a short-lived EdDSA JWT against our published JWKS and
|
|
3
|
+
// asserts the expected action (+ optional resource + tier); `verifyHumanSignup` is
|
|
4
|
+
// the consumer-signup convenience. Both fail closed. See docs/ARCHITECTURE.md §12,
|
|
5
|
+
// §18.6, §18.7.
|
|
6
|
+
export { verifyHumanAction, verifyHumanSignup } from "./verify.js";
|
|
7
|
+
export { hasSeenJti, MemoryJtiStore } from "./jti.js";
|
package/dist/jti.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Merchant-supplied store. `add` should set its own TTL (token lives ≤5 min). */
|
|
2
|
+
export interface JtiStore {
|
|
3
|
+
has(jti: string): boolean | Promise<boolean>;
|
|
4
|
+
add(jti: string, expiresAtMs?: number): void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if this `jti` was already seen (caller should reject); otherwise
|
|
8
|
+
* records it and returns false. `ttlMs` defaults to the 5-min token lifetime.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hasSeenJti(jti: string, store: JtiStore, ttlMs?: number): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Reference in-memory JtiStore for tests/single-process demos. Production should
|
|
13
|
+
* use a shared store (Redis/DB) so dedupe holds across replicas. Prunes lazily.
|
|
14
|
+
*/
|
|
15
|
+
export declare class MemoryJtiStore implements JtiStore {
|
|
16
|
+
private readonly seen;
|
|
17
|
+
has(jti: string): boolean;
|
|
18
|
+
add(jti: string, expiresAtMs?: number): void;
|
|
19
|
+
private prune;
|
|
20
|
+
}
|
package/dist/jti.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Optional replay-dedupe helper (ARCHITECTURE §18.6). The JWT is already
|
|
2
|
+
// single-purpose, domain+action-bound, and 5-min TTL — but a merchant SHOULD
|
|
3
|
+
// also reject a `jti` they've already honored, so the same token can't create
|
|
4
|
+
// two accounts within its window. We never touch the merchant's store for them;
|
|
5
|
+
// they wire `hasSeenJti` to their own DB/Redis via the JtiStore interface.
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if this `jti` was already seen (caller should reject); otherwise
|
|
8
|
+
* records it and returns false. `ttlMs` defaults to the 5-min token lifetime.
|
|
9
|
+
*/
|
|
10
|
+
export async function hasSeenJti(jti, store, ttlMs = 300_000) {
|
|
11
|
+
if (await store.has(jti))
|
|
12
|
+
return true;
|
|
13
|
+
await store.add(jti, Date.now() + ttlMs);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Reference in-memory JtiStore for tests/single-process demos. Production should
|
|
18
|
+
* use a shared store (Redis/DB) so dedupe holds across replicas. Prunes lazily.
|
|
19
|
+
*/
|
|
20
|
+
export class MemoryJtiStore {
|
|
21
|
+
seen = new Map();
|
|
22
|
+
has(jti) {
|
|
23
|
+
const expiresAt = this.seen.get(jti);
|
|
24
|
+
if (expiresAt === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
if (expiresAt <= Date.now()) {
|
|
27
|
+
this.seen.delete(jti);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
add(jti, expiresAtMs = Date.now() + 300_000) {
|
|
33
|
+
this.prune();
|
|
34
|
+
this.seen.set(jti, expiresAtMs);
|
|
35
|
+
}
|
|
36
|
+
prune() {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
for (const [jti, expiresAt] of this.seen) {
|
|
39
|
+
if (expiresAt <= now)
|
|
40
|
+
this.seen.delete(jti);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/jwks.d.ts
ADDED
package/dist/jwks.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Memoized issuer-JWKS resolver. `createRemoteJWKSet` returns a key-resolving
|
|
2
|
+
// function with its own internal cache + auto-refresh-on-unknown-`kid`; we cache
|
|
3
|
+
// that function per URL so a long-running merchant process reuses the cache
|
|
4
|
+
// instead of re-fetching on every signup. Keeps our uptime off the merchant's
|
|
5
|
+
// critical path (ARCHITECTURE §18.7).
|
|
6
|
+
import { createRemoteJWKSet } from "jose";
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
export function remoteJwks(jwksUrl) {
|
|
9
|
+
let resolver = cache.get(jwksUrl);
|
|
10
|
+
if (!resolver) {
|
|
11
|
+
resolver = createRemoteJWKSet(new URL(jwksUrl));
|
|
12
|
+
cache.set(jwksUrl, resolver);
|
|
13
|
+
}
|
|
14
|
+
return resolver;
|
|
15
|
+
}
|
|
16
|
+
/** Test-only: drop the memoized resolvers so a fresh URL/key set is used. */
|
|
17
|
+
export function clearJwksCache() {
|
|
18
|
+
cache.clear();
|
|
19
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { JWTVerifyGetKey } from "jose";
|
|
2
|
+
import type { Action, AuthLevel, Resource } from "@tiertwo/shared";
|
|
3
|
+
/**
|
|
4
|
+
* Why a verification failed. Returned (never thrown) so the merchant fails closed
|
|
5
|
+
* but can still log/debug. None of these should be treated as success.
|
|
6
|
+
*/
|
|
7
|
+
export type FailureReason = "no_token" | "no_domain" | "malformed" | "bad_signature" | "expired" | "not_yet_valid" | "wrong_issuer" | "wrong_audience" | "wrong_action" | "wrong_resource" | "insufficient_auth_level" | "jwks_unreachable";
|
|
8
|
+
/** Successful verification — exactly the §12 shape the merchant consumes. */
|
|
9
|
+
export type VerifySuccess = {
|
|
10
|
+
valid: true;
|
|
11
|
+
/** The `verified_human` claim (derived: auth_level === "identity_proofed"). */
|
|
12
|
+
verified: boolean;
|
|
13
|
+
/** ISO-3166 alpha-2, or null when unknown. */
|
|
14
|
+
country: string | null;
|
|
15
|
+
age_over_18: boolean;
|
|
16
|
+
/** Pairwise/directed subject — stable per (user, this merchant). (§18.3) */
|
|
17
|
+
sub: string;
|
|
18
|
+
/** The device that produced the request (`device_id` claim). */
|
|
19
|
+
deviceId: string;
|
|
20
|
+
/** Unique token id — merchant SHOULD dedupe via hasSeenJti (§18.6). */
|
|
21
|
+
jti: string;
|
|
22
|
+
/** The bound action (a validated string, e.g. "create_account", "deploy"). */
|
|
23
|
+
action: Action;
|
|
24
|
+
/** The bound resource scope, or null for an unscoped action. */
|
|
25
|
+
resource: string | null;
|
|
26
|
+
/** The assurance tier the token was minted at. */
|
|
27
|
+
authLevel: AuthLevel;
|
|
28
|
+
};
|
|
29
|
+
export type VerifyFailure = {
|
|
30
|
+
valid: false;
|
|
31
|
+
reason: FailureReason;
|
|
32
|
+
};
|
|
33
|
+
/** Discriminated union — narrow on `valid`. */
|
|
34
|
+
export type VerifyResult = VerifySuccess | VerifyFailure;
|
|
35
|
+
/** A `jose` key resolver, e.g. the function returned by createRemoteJWKSet. */
|
|
36
|
+
export type KeyResolver = JWTVerifyGetKey;
|
|
37
|
+
export type VerifyOptions = {
|
|
38
|
+
/**
|
|
39
|
+
* Merchant domain the token must be bound to (`aud`). Defaults to the
|
|
40
|
+
* `TIERTWO_DOMAIN` env var so the call can be argument-free.
|
|
41
|
+
*/
|
|
42
|
+
domain?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Issuer base URL — drives both the `iss` claim check and the JWKS location.
|
|
45
|
+
* Defaults to `TIERTWO_ISSUER_URL` env var, then `https://trytiertwo.com`.
|
|
46
|
+
*/
|
|
47
|
+
issuer?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Full JWKS URL. Defaults to `${issuer}/.well-known/jwks.json`.
|
|
50
|
+
*/
|
|
51
|
+
jwksUrl?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Advanced/testing: inject a `jose` key resolver (e.g. createLocalJWKSet) to
|
|
54
|
+
* verify fully offline. When set, `jwksUrl` is ignored.
|
|
55
|
+
*/
|
|
56
|
+
jwks?: KeyResolver;
|
|
57
|
+
};
|
|
58
|
+
/** Options for `verifyHumanAction` — a VerifyOptions plus the expectations to enforce. */
|
|
59
|
+
export type VerifyActionOptions = VerifyOptions & {
|
|
60
|
+
/** The action the token MUST carry (exact match) — else `wrong_action`. */
|
|
61
|
+
action: Action;
|
|
62
|
+
/**
|
|
63
|
+
* If set, the token's `resource` must equal this exactly — else `wrong_resource`.
|
|
64
|
+
* Omit to accept any resource (including none).
|
|
65
|
+
*/
|
|
66
|
+
expectedResource?: Resource;
|
|
67
|
+
/**
|
|
68
|
+
* If set, the token's `auth_level` must be at least this tier — else
|
|
69
|
+
* `insufficient_auth_level`. Omit to accept any tier.
|
|
70
|
+
*/
|
|
71
|
+
requiredAuthLevel?: AuthLevel;
|
|
72
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { VerifyActionOptions, VerifyOptions, VerifyResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Verify a request carrying `Authorization: Bearer <jwt>` (or a raw token) and
|
|
4
|
+
* assert it authorizes a specific action (with optional resource + minimum tier).
|
|
5
|
+
*
|
|
6
|
+
* @param req Web `Request`, Node `IncomingMessage`, or a raw token string.
|
|
7
|
+
* @param opts `{ action, expectedResource?, requiredAuthLevel?, domain, issuer,
|
|
8
|
+
* jwksUrl, jwks }` — `domain`/`issuer` default from `TIERTWO_DOMAIN` /
|
|
9
|
+
* `TIERTWO_ISSUER_URL`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function verifyHumanAction(req: unknown, opts: VerifyActionOptions): Promise<VerifyResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Consumer-signup convenience: verify a token authorizes `create_account` at the
|
|
14
|
+
* identity-proofed tier. One line over `verifyHumanAction`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function verifyHumanSignup(req: unknown, opts?: VerifyOptions): Promise<VerifyResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// verifyHumanAction — the merchant/enforcer's one-verb integration (ARCHITECTURE §12).
|
|
2
|
+
// Verifies a short-lived EdDSA JWT minted by the Tier Two issuer against our
|
|
3
|
+
// published JWKS, enforces the expected action (and optional resource + assurance
|
|
4
|
+
// tier), and fails CLOSED on every error path: any problem returns
|
|
5
|
+
// { valid: false, reason } — never a throw a caller might treat as success.
|
|
6
|
+
import { jwtVerify, errors } from "jose";
|
|
7
|
+
import { AUTH_LEVELS, meetsAuthLevel } from "@tiertwo/shared";
|
|
8
|
+
import { extractBearer } from "./extract.js";
|
|
9
|
+
import { remoteJwks } from "./jwks.js";
|
|
10
|
+
const DEFAULT_ISSUER = "https://trytiertwo.com";
|
|
11
|
+
const CLOCK_SKEW_SECONDS = 30; // ±30s tolerance (§18.6)
|
|
12
|
+
function fail(reason) {
|
|
13
|
+
return { valid: false, reason };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Verify a request carrying `Authorization: Bearer <jwt>` (or a raw token) and
|
|
17
|
+
* assert it authorizes a specific action (with optional resource + minimum tier).
|
|
18
|
+
*
|
|
19
|
+
* @param req Web `Request`, Node `IncomingMessage`, or a raw token string.
|
|
20
|
+
* @param opts `{ action, expectedResource?, requiredAuthLevel?, domain, issuer,
|
|
21
|
+
* jwksUrl, jwks }` — `domain`/`issuer` default from `TIERTWO_DOMAIN` /
|
|
22
|
+
* `TIERTWO_ISSUER_URL`.
|
|
23
|
+
*/
|
|
24
|
+
export async function verifyHumanAction(req, opts) {
|
|
25
|
+
try {
|
|
26
|
+
const token = extractBearer(req);
|
|
27
|
+
if (!token)
|
|
28
|
+
return fail("no_token");
|
|
29
|
+
const domain = opts.domain ?? process.env.TIERTWO_DOMAIN;
|
|
30
|
+
if (!domain)
|
|
31
|
+
return fail("no_domain");
|
|
32
|
+
const issuer = opts.issuer ?? process.env.TIERTWO_ISSUER_URL ?? DEFAULT_ISSUER;
|
|
33
|
+
const keyResolver = opts.jwks ??
|
|
34
|
+
remoteJwks(opts.jwksUrl ?? `${issuer.replace(/\/$/, "")}/.well-known/jwks.json`);
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
({ payload } = await jwtVerify(token, keyResolver, {
|
|
38
|
+
issuer, // enforces `iss`
|
|
39
|
+
audience: domain, // enforces `aud === domain`
|
|
40
|
+
algorithms: ["EdDSA"], // blocks alg:none + HS256 key-confusion
|
|
41
|
+
clockTolerance: CLOCK_SKEW_SECONDS, // covers exp/nbf/iat skew
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return fail(mapError(err));
|
|
46
|
+
}
|
|
47
|
+
// Claims jose can't validate for us: exact action, optional resource + tier.
|
|
48
|
+
const action = payload.action;
|
|
49
|
+
if (typeof action !== "string" || action !== opts.action) {
|
|
50
|
+
return fail("wrong_action");
|
|
51
|
+
}
|
|
52
|
+
const resource = typeof payload.resource === "string" ? payload.resource : null;
|
|
53
|
+
if (opts.expectedResource !== undefined && resource !== opts.expectedResource) {
|
|
54
|
+
return fail("wrong_resource");
|
|
55
|
+
}
|
|
56
|
+
const authLevel = payload.auth_level;
|
|
57
|
+
if (typeof authLevel !== "string" ||
|
|
58
|
+
!AUTH_LEVELS.includes(authLevel)) {
|
|
59
|
+
return fail("malformed");
|
|
60
|
+
}
|
|
61
|
+
if (opts.requiredAuthLevel !== undefined &&
|
|
62
|
+
!meetsAuthLevel(authLevel, opts.requiredAuthLevel)) {
|
|
63
|
+
return fail("insufficient_auth_level");
|
|
64
|
+
}
|
|
65
|
+
// Required, well-typed identity fields.
|
|
66
|
+
const { sub, jti } = payload;
|
|
67
|
+
const deviceId = payload.device_id;
|
|
68
|
+
if (typeof sub !== "string" ||
|
|
69
|
+
typeof jti !== "string" ||
|
|
70
|
+
typeof deviceId !== "string") {
|
|
71
|
+
return fail("malformed");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
valid: true,
|
|
75
|
+
verified: payload.verified_human === true,
|
|
76
|
+
country: typeof payload.country === "string" ? payload.country : null,
|
|
77
|
+
age_over_18: payload.age_over_18 === true,
|
|
78
|
+
sub,
|
|
79
|
+
deviceId,
|
|
80
|
+
jti,
|
|
81
|
+
action: action,
|
|
82
|
+
resource,
|
|
83
|
+
authLevel: authLevel,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Absolute backstop — verification must never throw.
|
|
88
|
+
return fail("bad_signature");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Consumer-signup convenience: verify a token authorizes `create_account` at the
|
|
93
|
+
* identity-proofed tier. One line over `verifyHumanAction`.
|
|
94
|
+
*/
|
|
95
|
+
export async function verifyHumanSignup(req, opts = {}) {
|
|
96
|
+
return verifyHumanAction(req, {
|
|
97
|
+
...opts,
|
|
98
|
+
action: "create_account",
|
|
99
|
+
requiredAuthLevel: "identity_proofed",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Map a `jose` (or network) error to a stable, fail-closed reason. */
|
|
103
|
+
function mapError(err) {
|
|
104
|
+
// Network/fetch failures during JWKS retrieval aren't JOSEErrors → unreachable.
|
|
105
|
+
if (!(err instanceof errors.JOSEError))
|
|
106
|
+
return "jwks_unreachable";
|
|
107
|
+
switch (err.code) {
|
|
108
|
+
case "ERR_JWT_EXPIRED":
|
|
109
|
+
return "expired";
|
|
110
|
+
case "ERR_JWT_CLAIM_VALIDATION_FAILED": {
|
|
111
|
+
const claim = err.claim;
|
|
112
|
+
if (claim === "aud")
|
|
113
|
+
return "wrong_audience";
|
|
114
|
+
if (claim === "iss")
|
|
115
|
+
return "wrong_issuer";
|
|
116
|
+
if (claim === "nbf")
|
|
117
|
+
return "not_yet_valid";
|
|
118
|
+
return "malformed";
|
|
119
|
+
}
|
|
120
|
+
case "ERR_JWS_SIGNATURE_VERIFICATION_FAILED":
|
|
121
|
+
case "ERR_JOSE_ALG_NOT_ALLOWED":
|
|
122
|
+
case "ERR_JWKS_NO_MATCHING_KEY":
|
|
123
|
+
case "ERR_JWKS_MULTIPLE_MATCHING_KEYS":
|
|
124
|
+
return "bad_signature";
|
|
125
|
+
case "ERR_JWKS_TIMEOUT":
|
|
126
|
+
case "ERR_JWKS_INVALID":
|
|
127
|
+
return "jwks_unreachable";
|
|
128
|
+
case "ERR_JWT_INVALID":
|
|
129
|
+
case "ERR_JWS_INVALID":
|
|
130
|
+
case "ERR_JWE_INVALID":
|
|
131
|
+
return "malformed";
|
|
132
|
+
default:
|
|
133
|
+
return "bad_signature"; // unknown JOSE error → fail closed
|
|
134
|
+
}
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiertwo/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-call merchant SDK to verify Tier Two verified-human signup tokens — offline EdDSA/JWKS verification, fail-closed, with a jti replay-dedupe helper.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Tier Two",
|
|
8
|
+
"homepage": "https://github.com/Jordan-M/verified-human/tree/master/packages/sdk#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Jordan-M/verified-human.git",
|
|
12
|
+
"directory": "packages/sdk"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Jordan-M/verified-human/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tiertwo",
|
|
19
|
+
"verified-human",
|
|
20
|
+
"jwt",
|
|
21
|
+
"jwks",
|
|
22
|
+
"eddsa",
|
|
23
|
+
"authentication",
|
|
24
|
+
"proof-of-personhood",
|
|
25
|
+
"mcp"
|
|
26
|
+
],
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"jose": "^6.2.3",
|
|
44
|
+
"@tiertwo/shared": "0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/express": "^5.0.1",
|
|
48
|
+
"@types/node": "^20",
|
|
49
|
+
"express": "^5.1.0",
|
|
50
|
+
"tsx": "^4.22.4",
|
|
51
|
+
"typescript": "^5",
|
|
52
|
+
"vitest": "^3.2.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc -p tsconfig.json",
|
|
56
|
+
"lint": "echo no-op",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"example:express": "tsx examples/express/server.ts"
|
|
60
|
+
}
|
|
61
|
+
}
|