@cplieger/access 0.0.0 → 1.0.0-dev.10
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 +64 -1
- package/jsr.json +22 -0
- package/package.json +50 -10
- package/src/client.ts +351 -0
- package/src/continuation.ts +174 -0
- package/src/index.ts +125 -0
- package/src/navigate.ts +5 -0
- package/src/oidc.ts +77 -0
- package/src/password-credential.ts +30 -0
- package/src/secret.ts +42 -0
- package/src/sessions.ts +63 -0
- package/src/stepup.ts +112 -0
- package/src/webauthn.ts +500 -0
- package/src/wire.ts +910 -0
package/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
1
|
# @cplieger/access
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@cplieger/access` is the browser half of [cplieger/access](https://github.com/cplieger/access): a typed client for the JSON routes the Go package `accesshttp` serves, with the passkey ceremonies, OIDC redirects and step-up flow those routes expect. Every response is validated against the server's wire types before your code sees it.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @cplieger/access @cplieger/reactive@dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package ships TypeScript source as ES modules. `@cplieger/reactive` 2.x, from 2.4, is a peer dependency: the client makes its requests through your `@cplieger/reactive/fetch` instance, so your base URL, credentials mode and error hook apply to every call, and the models that `createSessionsModel` and `shownOnce` return are `@cplieger/reactive/signal` signals, so read them from effects made with that same package.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Create one client with the same prefixes you gave `accesshttp.Config`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createFetch } from "@cplieger/reactive/fetch";
|
|
19
|
+
import { createAuthClient, signInWithPasskey, withStepUp } from "@cplieger/access";
|
|
20
|
+
|
|
21
|
+
const auth = createAuthClient({
|
|
22
|
+
fetch: createFetch({ credentials: "same-origin" }),
|
|
23
|
+
prefix: "/api/auth",
|
|
24
|
+
oidcPrefix: "/api/auth/oidc",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const res = await auth.loginPassword({ username, password });
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
// authCode(res) and authReason(res) give the stable code and reason to branch on
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const passkey = await signInWithPasskey(auth);
|
|
33
|
+
if (passkey.kind === "signed-in") {
|
|
34
|
+
// passkey.result is the SignedInResponse
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every call returns an `ApiResult`: `ok` with the decoded `data`, or the refusal. `authCode` and `authReason` read the refusal's code and reason as the `AuthCode` and `AuthReason` unions, which mirror the server's `Codes()` and `Reasons()`.
|
|
39
|
+
|
|
40
|
+
`withStepUp` runs an action that needs a recent sign-in. On an `auth_recent_auth_required` refusal with no reason, it asks your `prompt` which method to use, re-authenticates with it and retries the action once. A refusal that carries a reason, such as `reenable_needs_other_method`, is returned as it is, because a step-up cannot cure it:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const out = await withStepUp(auth, () => auth.setPassword(change), {
|
|
44
|
+
prompt: async (methods) => askUser(methods), // "password", "passkey", "oidc" or "cancel"
|
|
45
|
+
password: async () => askForCurrentPassword(), // null when the user cancels
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The OIDC branch navigates to the identity provider and never returns to the action, so the app continues from its own redirect path.
|
|
50
|
+
|
|
51
|
+
## What it exports
|
|
52
|
+
|
|
53
|
+
- `createAuthClient` returns an `AuthClient` with one method per route, such as `me`, `loginPassword`, `sessions` and `createKey`. `adminSecurityLog` reads a page of the [security log](https://github.com/cplieger/access/blob/main/docs/security-log.md) with its per-class counts, newest first, or oldest first when `oldestFirst` is set; `after` and `before` are bounds only.
|
|
54
|
+
- `signInWithPasskey`, `registerPasskey`, `setupWithPasskey`, `stepUpWithPasskey` and `recoverWithPasskey` run the WebAuthn ceremonies. `passkeySupport` reports what the browser offers, and `sendSignals` keeps the credential manager's passkey list in step with the server.
|
|
55
|
+
- `oidcSignInURL`, `startOIDC`, `readOIDCReturn`, `pendingLink` and `completePendingLink` handle the OIDC sign-in, link and step-up redirects.
|
|
56
|
+
- `withStepUp` retries an action after a step-up, and `createSessionsModel` keeps a reactive list of the user's sessions.
|
|
57
|
+
- `shownOnce` holds a key or link shown once and clears it, `storePasswordCredential` offers the password to the browser's password manager, and `continuation` and `takeFragmentToken` carry state and a one-time token across a redirect.
|
|
58
|
+
- A `parse*` validator and a type for every JSON body the routes accept or return (the query structs are built by the client methods), `authCode`, `authReason`, `AuthCode` and `AuthReason`.
|
|
59
|
+
|
|
60
|
+
## Compatibility
|
|
61
|
+
|
|
62
|
+
The client speaks the v1.0.0 wire. It needs a server built on `github.com/cplieger/access` at v1.0.0 or later, with `accesshttp` serving the routes. The Go module and this package version separately, and a later release states its own floor here.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
package/jsr.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cplieger/access",
|
|
3
|
+
"version": "1.0.0-dev.10",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"publish": {
|
|
9
|
+
"include": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"NOTICE",
|
|
12
|
+
"README.md",
|
|
13
|
+
"src/**/*.ts",
|
|
14
|
+
"jsr.json"
|
|
15
|
+
],
|
|
16
|
+
"exclude": [
|
|
17
|
+
"src/**/*.test.ts",
|
|
18
|
+
"src/**/*-setup.ts",
|
|
19
|
+
"src/**/test-helpers/**"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/access",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Browser client for the cplieger/access
|
|
3
|
+
"version": "1.0.0-dev.10",
|
|
4
|
+
"description": "Browser client for the cplieger/access accesshttp routes: typed calls, passkey ceremonies and step-up",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"bugs": {
|
|
9
|
-
"url": "https://github.com/cplieger/access/issues"
|
|
10
|
-
},
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
11
8
|
"repository": {
|
|
12
9
|
"type": "git",
|
|
13
10
|
"url": "git+https://github.com/cplieger/access.git",
|
|
14
11
|
"directory": "web"
|
|
15
12
|
},
|
|
16
|
-
"
|
|
13
|
+
"homepage": "https://github.com/cplieger/access",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/cplieger/access/issues"
|
|
16
|
+
},
|
|
17
|
+
"author": "cplieger <917744+cplieger@users.noreply.github.com>",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
17
21
|
"files": [
|
|
18
|
-
"
|
|
22
|
+
"src/",
|
|
23
|
+
"!src/**/*.test.ts",
|
|
24
|
+
"!src/**/*-setup.ts",
|
|
25
|
+
"!src/**/test-helpers/**",
|
|
26
|
+
"jsr.json",
|
|
19
27
|
"LICENSE",
|
|
20
28
|
"NOTICE"
|
|
21
|
-
]
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.tests.json",
|
|
32
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json",
|
|
33
|
+
"test": "vitest --run",
|
|
34
|
+
"test:browser": "vitest --run --project browser",
|
|
35
|
+
"lint:eslint": "eslint .",
|
|
36
|
+
"lint:prettier": "prettier --check .",
|
|
37
|
+
"lint:knip": "knip",
|
|
38
|
+
"lint:fix": "eslint . --fix && prettier --write ."
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@cplieger/reactive": "2.4.0-dev.1",
|
|
42
|
+
"@eslint/js": "10.0.1",
|
|
43
|
+
"@types/node": "26.6.6",
|
|
44
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
45
|
+
"@vitest/browser-playwright": "5.0.5",
|
|
46
|
+
"eslint": "10.13.0",
|
|
47
|
+
"fast-check": "4.10.2",
|
|
48
|
+
"knip": "6.44.0",
|
|
49
|
+
"playwright": "1.64.1",
|
|
50
|
+
"prettier": "3.9.10",
|
|
51
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
52
|
+
"typescript-eslint": "8.72.1",
|
|
53
|
+
"vite": "8.3.5",
|
|
54
|
+
"vitest": "5.0.5"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"minimatch": "^11.0.1"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@cplieger/reactive": "^2.4.0-dev.1"
|
|
61
|
+
}
|
|
22
62
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiResult,
|
|
3
|
+
FetchInstance,
|
|
4
|
+
HttpMethod,
|
|
5
|
+
RequestOptions,
|
|
6
|
+
} from "@cplieger/reactive/fetch";
|
|
7
|
+
import {
|
|
8
|
+
list,
|
|
9
|
+
parseCeremonyResponse,
|
|
10
|
+
parseKeyCreatedResponse,
|
|
11
|
+
parseKeyView,
|
|
12
|
+
parseMeResponse,
|
|
13
|
+
parseOIDCStartResponse,
|
|
14
|
+
parsePasskeyView,
|
|
15
|
+
parsePendingLinkResponse,
|
|
16
|
+
parseRecoveryResponse,
|
|
17
|
+
parseSecurityLogView,
|
|
18
|
+
parseSessionView,
|
|
19
|
+
parseSignedInResponse,
|
|
20
|
+
parseStateResponse,
|
|
21
|
+
parseTokenResponse,
|
|
22
|
+
parseTokenView,
|
|
23
|
+
parseUserView,
|
|
24
|
+
parseWebAuthnSignals,
|
|
25
|
+
} from "./wire.js";
|
|
26
|
+
import type {
|
|
27
|
+
CeremonyResponse,
|
|
28
|
+
InviteAcceptRequest,
|
|
29
|
+
InviteRequest,
|
|
30
|
+
KeyCreatedResponse,
|
|
31
|
+
KeyView,
|
|
32
|
+
LoginRequest,
|
|
33
|
+
MeResponse,
|
|
34
|
+
NameRequest,
|
|
35
|
+
NewKeyRequest,
|
|
36
|
+
NewUserRequest,
|
|
37
|
+
OIDCStartResponse,
|
|
38
|
+
PasskeyView,
|
|
39
|
+
PasswordChangeRequest,
|
|
40
|
+
PasswordRemoveRequest,
|
|
41
|
+
PasswordRequest,
|
|
42
|
+
PendingLinkResponse,
|
|
43
|
+
ProfileRequest,
|
|
44
|
+
RecoverPasswordRequest,
|
|
45
|
+
RecoveryResponse,
|
|
46
|
+
RedirectRequest,
|
|
47
|
+
SecurityLogView,
|
|
48
|
+
SessionView,
|
|
49
|
+
SetupPasskeyBeginRequest,
|
|
50
|
+
SetupPasswordRequest,
|
|
51
|
+
SignedInResponse,
|
|
52
|
+
StateResponse,
|
|
53
|
+
TTLRequest,
|
|
54
|
+
TokenRequest,
|
|
55
|
+
TokenResponse,
|
|
56
|
+
TokenView,
|
|
57
|
+
UserUpdateRequest,
|
|
58
|
+
UserView,
|
|
59
|
+
Validator,
|
|
60
|
+
WebAuthnSignals,
|
|
61
|
+
} from "./wire.js";
|
|
62
|
+
|
|
63
|
+
export interface AuthClientOptions {
|
|
64
|
+
/** The app's instance: its credentials mode, base URL and error hook apply to every call. */
|
|
65
|
+
readonly fetch: FetchInstance;
|
|
66
|
+
/** accesshttp.Config.Prefix. */
|
|
67
|
+
readonly prefix: string;
|
|
68
|
+
/** accesshttp.Config.OIDCPrefix. */
|
|
69
|
+
readonly oidcPrefix: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A finish route's body as the ceremonies build it: clientExtensionResults carries only an output
|
|
74
|
+
* the begin options requested (credProps on the create leg), because go-webauthn refuses any other.
|
|
75
|
+
*/
|
|
76
|
+
export type PublicKeyCredentialJSON =
|
|
77
|
+
| {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly rawId: string;
|
|
80
|
+
readonly type: string;
|
|
81
|
+
readonly authenticatorAttachment?: string;
|
|
82
|
+
readonly response: {
|
|
83
|
+
readonly attestationObject: string;
|
|
84
|
+
readonly clientDataJSON: string;
|
|
85
|
+
readonly transports?: readonly string[];
|
|
86
|
+
};
|
|
87
|
+
readonly clientExtensionResults: { readonly credProps?: { readonly rk?: boolean } };
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly rawId: string;
|
|
92
|
+
readonly type: string;
|
|
93
|
+
readonly authenticatorAttachment?: string;
|
|
94
|
+
readonly response: {
|
|
95
|
+
readonly authenticatorData: string;
|
|
96
|
+
readonly clientDataJSON: string;
|
|
97
|
+
readonly signature: string;
|
|
98
|
+
readonly userHandle?: string;
|
|
99
|
+
};
|
|
100
|
+
readonly clientExtensionResults: { readonly credProps?: { readonly rk?: boolean } };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export interface RecoverFlags {
|
|
104
|
+
readonly keepSessions: boolean;
|
|
105
|
+
readonly keepKeys: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One method per JSON route of accesshttp, each decoded through wire.ts: a 2xx body that is not
|
|
110
|
+
* the route's type is the fetch instance's "decode" failure. The two redirect routes are
|
|
111
|
+
* navigations, built by oidcSignInURL.
|
|
112
|
+
*/
|
|
113
|
+
export interface AuthClient {
|
|
114
|
+
readonly prefix: string;
|
|
115
|
+
readonly oidcPrefix: string;
|
|
116
|
+
state(): Promise<ApiResult<StateResponse>>;
|
|
117
|
+
setupPassword(r: SetupPasswordRequest): Promise<ApiResult<SignedInResponse>>;
|
|
118
|
+
setupPasskeyBegin(r: SetupPasskeyBeginRequest): Promise<ApiResult<CeremonyResponse>>;
|
|
119
|
+
setupPasskeyFinish(
|
|
120
|
+
ceremony: string,
|
|
121
|
+
credential: PublicKeyCredentialJSON,
|
|
122
|
+
): Promise<ApiResult<SignedInResponse>>;
|
|
123
|
+
loginPassword(r: LoginRequest): Promise<ApiResult<SignedInResponse>>;
|
|
124
|
+
loginPasskeyBegin(o?: {
|
|
125
|
+
readonly mediation?: "conditional";
|
|
126
|
+
}): Promise<ApiResult<CeremonyResponse>>;
|
|
127
|
+
loginPasskeyFinish(
|
|
128
|
+
ceremony: string,
|
|
129
|
+
credential: PublicKeyCredentialJSON,
|
|
130
|
+
): Promise<ApiResult<SignedInResponse>>;
|
|
131
|
+
logout(): Promise<ApiResult<void>>;
|
|
132
|
+
oidcStartLink(r: RedirectRequest): Promise<ApiResult<OIDCStartResponse>>;
|
|
133
|
+
oidcStartStepUp(r: RedirectRequest): Promise<ApiResult<OIDCStartResponse>>;
|
|
134
|
+
oidcPending(): Promise<ApiResult<PendingLinkResponse>>;
|
|
135
|
+
oidcLinkPending(): Promise<ApiResult<MeResponse>>;
|
|
136
|
+
oidcUnlink(): Promise<ApiResult<void>>;
|
|
137
|
+
recoveryStart(r: TokenRequest): Promise<ApiResult<RecoveryResponse>>;
|
|
138
|
+
recoveryPassword(r: RecoverPasswordRequest): Promise<ApiResult<void>>;
|
|
139
|
+
recoveryPasskeyBegin(): Promise<ApiResult<CeremonyResponse>>;
|
|
140
|
+
recoveryPasskeyFinish(
|
|
141
|
+
ceremony: string,
|
|
142
|
+
credential: PublicKeyCredentialJSON,
|
|
143
|
+
o: RecoverFlags,
|
|
144
|
+
): Promise<ApiResult<void>>;
|
|
145
|
+
inviteAcceptPassword(r: InviteAcceptRequest): Promise<ApiResult<SignedInResponse>>;
|
|
146
|
+
me(): Promise<ApiResult<MeResponse>>;
|
|
147
|
+
updateProfile(r: ProfileRequest): Promise<ApiResult<MeResponse>>;
|
|
148
|
+
reenableMethod(method: string): Promise<ApiResult<void>>;
|
|
149
|
+
stepUpPassword(r: PasswordRequest): Promise<ApiResult<void>>;
|
|
150
|
+
stepUpPasskeyBegin(): Promise<ApiResult<CeremonyResponse>>;
|
|
151
|
+
stepUpPasskeyFinish(
|
|
152
|
+
ceremony: string,
|
|
153
|
+
credential: PublicKeyCredentialJSON,
|
|
154
|
+
): Promise<ApiResult<void>>;
|
|
155
|
+
setPassword(r: PasswordChangeRequest): Promise<ApiResult<void>>;
|
|
156
|
+
removePassword(r: PasswordRemoveRequest): Promise<ApiResult<void>>;
|
|
157
|
+
passkeys(): Promise<ApiResult<readonly PasskeyView[]>>;
|
|
158
|
+
passkeyRegisterBegin(): Promise<ApiResult<CeremonyResponse>>;
|
|
159
|
+
passkeyRegisterFinish(
|
|
160
|
+
ceremony: string,
|
|
161
|
+
credential: PublicKeyCredentialJSON,
|
|
162
|
+
): Promise<ApiResult<PasskeyView>>;
|
|
163
|
+
renamePasskey(id: number, r: NameRequest): Promise<ApiResult<void>>;
|
|
164
|
+
removePasskey(id: number): Promise<ApiResult<void>>;
|
|
165
|
+
webauthnSignals(): Promise<ApiResult<WebAuthnSignals>>;
|
|
166
|
+
sessions(): Promise<ApiResult<readonly SessionView[]>>;
|
|
167
|
+
renameSession(id: string, r: NameRequest): Promise<ApiResult<void>>;
|
|
168
|
+
revokeSession(id: string): Promise<ApiResult<void>>;
|
|
169
|
+
revokeOtherSessions(): Promise<ApiResult<void>>;
|
|
170
|
+
keys(): Promise<ApiResult<readonly KeyView[]>>;
|
|
171
|
+
createKey(r: NewKeyRequest): Promise<ApiResult<KeyCreatedResponse>>;
|
|
172
|
+
revokeKey(id: number): Promise<ApiResult<void>>;
|
|
173
|
+
/** key lists the users any key in that key's lineage acted on (GET /users?key=). */
|
|
174
|
+
adminUsers(o?: { readonly key?: number }): Promise<ApiResult<readonly UserView[]>>;
|
|
175
|
+
adminCreateUser(r: NewUserRequest): Promise<ApiResult<UserView>>;
|
|
176
|
+
adminUpdateUser(id: number, r: UserUpdateRequest): Promise<ApiResult<UserView>>;
|
|
177
|
+
adminDeleteUser(id: number): Promise<ApiResult<void>>;
|
|
178
|
+
adminIssueRecovery(id: number, r: TTLRequest): Promise<ApiResult<TokenResponse>>;
|
|
179
|
+
adminInvite(r: InviteRequest): Promise<ApiResult<TokenResponse>>;
|
|
180
|
+
adminTokens(): Promise<ApiResult<readonly TokenView[]>>;
|
|
181
|
+
adminKeys(): Promise<ApiResult<readonly KeyView[]>>;
|
|
182
|
+
adminRevokeKey(id: number): Promise<ApiResult<void>>;
|
|
183
|
+
/**
|
|
184
|
+
* Newest first, or oldest first when oldestFirst is true; after and before are bounds only.
|
|
185
|
+
* kinds empty or absent is every kind.
|
|
186
|
+
*/
|
|
187
|
+
adminSecurityLog(q?: {
|
|
188
|
+
readonly user?: number;
|
|
189
|
+
readonly actor?: number;
|
|
190
|
+
readonly actorKey?: number;
|
|
191
|
+
readonly after?: number;
|
|
192
|
+
readonly before?: number;
|
|
193
|
+
readonly kinds?: readonly string[];
|
|
194
|
+
readonly limit?: number;
|
|
195
|
+
readonly oldestFirst?: boolean;
|
|
196
|
+
}): Promise<ApiResult<SecurityLogView>>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The fetch instance's decoder contract is to throw on a mismatch, which it turns into a
|
|
200
|
+
// "decode" failure; a wire.ts validator answers undefined instead.
|
|
201
|
+
function decoder<T>(validate: Validator<T>): (v: unknown) => T {
|
|
202
|
+
return (v) => {
|
|
203
|
+
const out = validate(v);
|
|
204
|
+
if (out === undefined) {
|
|
205
|
+
throw new TypeError("response body is not the route's wire type");
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function query(params: Readonly<Record<string, string | undefined>>): string {
|
|
212
|
+
const q = new URLSearchParams();
|
|
213
|
+
for (const [k, v] of Object.entries(params)) {
|
|
214
|
+
if (v !== undefined) {
|
|
215
|
+
q.set(k, v);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const s = q.toString();
|
|
219
|
+
return s === "" ? "" : `?${s}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const seg = encodeURIComponent;
|
|
223
|
+
const num = (n: number | undefined): string | undefined =>
|
|
224
|
+
n === undefined ? undefined : String(n);
|
|
225
|
+
|
|
226
|
+
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
|
227
|
+
const { fetch: f, prefix, oidcPrefix } = opts;
|
|
228
|
+
|
|
229
|
+
function json<T>(
|
|
230
|
+
method: HttpMethod,
|
|
231
|
+
path: string,
|
|
232
|
+
validate: Validator<T>,
|
|
233
|
+
o: RequestOptions<T> = {},
|
|
234
|
+
): Promise<ApiResult<T>> {
|
|
235
|
+
return f.requestRaw<T>(method, path, { ...o, decoder: decoder(validate) });
|
|
236
|
+
}
|
|
237
|
+
function empty(method: HttpMethod, path: string, body?: unknown): Promise<ApiResult<void>> {
|
|
238
|
+
return f.requestRaw<undefined>(method, path, {
|
|
239
|
+
ignoreBody: true,
|
|
240
|
+
...(body === undefined ? {} : { body }),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
function finish(
|
|
244
|
+
ceremony: string,
|
|
245
|
+
credential: PublicKeyCredentialJSON,
|
|
246
|
+
): { readonly body: unknown; readonly headers: Readonly<Record<string, string>> } {
|
|
247
|
+
return { body: credential, headers: { "X-Auth-Ceremony": ceremony } };
|
|
248
|
+
}
|
|
249
|
+
const p = (path: string): string => prefix + path;
|
|
250
|
+
const o = (path: string): string => oidcPrefix + path;
|
|
251
|
+
const passkeyList = list(parsePasskeyView);
|
|
252
|
+
const sessionList = list(parseSessionView);
|
|
253
|
+
const keyList = list(parseKeyView);
|
|
254
|
+
const userList = list(parseUserView);
|
|
255
|
+
const tokenList = list(parseTokenView);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
prefix,
|
|
259
|
+
oidcPrefix,
|
|
260
|
+
state: () => json("GET", p("/state"), parseStateResponse),
|
|
261
|
+
setupPassword: (r) => json("POST", p("/setup/password"), parseSignedInResponse, { body: r }),
|
|
262
|
+
setupPasskeyBegin: (r) =>
|
|
263
|
+
json("POST", p("/setup/passkey/begin"), parseCeremonyResponse, { body: r }),
|
|
264
|
+
setupPasskeyFinish: (c, cred) =>
|
|
265
|
+
json("POST", p("/setup/passkey/finish"), parseSignedInResponse, finish(c, cred)),
|
|
266
|
+
loginPassword: (r) => json("POST", p("/login/password"), parseSignedInResponse, { body: r }),
|
|
267
|
+
loginPasskeyBegin: (q) =>
|
|
268
|
+
json(
|
|
269
|
+
"POST",
|
|
270
|
+
p(`/login/passkey/begin${query({ mediation: q?.mediation })}`),
|
|
271
|
+
parseCeremonyResponse,
|
|
272
|
+
),
|
|
273
|
+
loginPasskeyFinish: (c, cred) =>
|
|
274
|
+
json("POST", p("/login/passkey/finish"), parseSignedInResponse, finish(c, cred)),
|
|
275
|
+
logout: () => empty("POST", p("/logout")),
|
|
276
|
+
oidcStartLink: (r) => json("POST", o("/start/link"), parseOIDCStartResponse, { body: r }),
|
|
277
|
+
oidcStartStepUp: (r) => json("POST", o("/start/stepup"), parseOIDCStartResponse, { body: r }),
|
|
278
|
+
oidcPending: () => json("GET", p("/oidc/pending"), parsePendingLinkResponse),
|
|
279
|
+
oidcLinkPending: () => json("POST", p("/oidc/link"), parseMeResponse),
|
|
280
|
+
oidcUnlink: () => empty("DELETE", p("/oidc/link")),
|
|
281
|
+
recoveryStart: (r) => json("POST", p("/recovery"), parseRecoveryResponse, { body: r }),
|
|
282
|
+
recoveryPassword: (r) => empty("POST", p("/recovery/password"), r),
|
|
283
|
+
recoveryPasskeyBegin: () => json("POST", p("/recovery/passkey/begin"), parseCeremonyResponse),
|
|
284
|
+
recoveryPasskeyFinish: (c, cred, flags) =>
|
|
285
|
+
f.requestRaw<undefined>(
|
|
286
|
+
"POST",
|
|
287
|
+
p(
|
|
288
|
+
`/recovery/passkey/finish${query({
|
|
289
|
+
keep_sessions: flags.keepSessions ? "true" : undefined,
|
|
290
|
+
keep_keys: flags.keepKeys ? "true" : undefined,
|
|
291
|
+
})}`,
|
|
292
|
+
),
|
|
293
|
+
{ ...finish(c, cred), ignoreBody: true },
|
|
294
|
+
),
|
|
295
|
+
inviteAcceptPassword: (r) =>
|
|
296
|
+
json("POST", p("/invite/password"), parseSignedInResponse, { body: r }),
|
|
297
|
+
me: () => json("GET", p("/me"), parseMeResponse),
|
|
298
|
+
updateProfile: (r) => json("PUT", p("/profile"), parseMeResponse, { body: r }),
|
|
299
|
+
reenableMethod: (m) => empty("POST", p(`/methods/${seg(m)}/reenable`)),
|
|
300
|
+
stepUpPassword: (r) => empty("POST", p("/stepup/password"), r),
|
|
301
|
+
stepUpPasskeyBegin: () => json("POST", p("/stepup/passkey/begin"), parseCeremonyResponse),
|
|
302
|
+
stepUpPasskeyFinish: (c, cred) =>
|
|
303
|
+
f.requestRaw<undefined>("POST", p("/stepup/passkey/finish"), {
|
|
304
|
+
...finish(c, cred),
|
|
305
|
+
ignoreBody: true,
|
|
306
|
+
}),
|
|
307
|
+
setPassword: (r) => empty("PUT", p("/password"), r),
|
|
308
|
+
removePassword: (r) => empty("DELETE", p("/password"), r),
|
|
309
|
+
passkeys: () => json("GET", p("/passkeys"), passkeyList),
|
|
310
|
+
passkeyRegisterBegin: () => json("POST", p("/passkeys/begin"), parseCeremonyResponse),
|
|
311
|
+
passkeyRegisterFinish: (c, cred) =>
|
|
312
|
+
json("POST", p("/passkeys/finish"), parsePasskeyView, finish(c, cred)),
|
|
313
|
+
renamePasskey: (id, r) => empty("PUT", p(`/passkeys/${String(id)}`), r),
|
|
314
|
+
removePasskey: (id) => empty("DELETE", p(`/passkeys/${String(id)}`)),
|
|
315
|
+
webauthnSignals: () => json("GET", p("/webauthn/signals"), parseWebAuthnSignals),
|
|
316
|
+
sessions: () => json("GET", p("/sessions"), sessionList),
|
|
317
|
+
renameSession: (id, r) => empty("PUT", p(`/sessions/${seg(id)}`), r),
|
|
318
|
+
revokeSession: (id) => empty("DELETE", p(`/sessions/${seg(id)}`)),
|
|
319
|
+
revokeOtherSessions: () => empty("POST", p("/sessions/revoke-others")),
|
|
320
|
+
keys: () => json("GET", p("/apikeys"), keyList),
|
|
321
|
+
createKey: (r) => json("POST", p("/apikeys"), parseKeyCreatedResponse, { body: r }),
|
|
322
|
+
revokeKey: (id) => empty("DELETE", p(`/apikeys/${String(id)}`)),
|
|
323
|
+
adminUsers: (q) => json("GET", p(`/users${query({ key: num(q?.key) })}`), userList),
|
|
324
|
+
adminCreateUser: (r) => json("POST", p("/users"), parseUserView, { body: r }),
|
|
325
|
+
adminUpdateUser: (id, r) => json("PUT", p(`/users/${String(id)}`), parseUserView, { body: r }),
|
|
326
|
+
adminDeleteUser: (id) => empty("DELETE", p(`/users/${String(id)}`)),
|
|
327
|
+
adminIssueRecovery: (id, r) =>
|
|
328
|
+
json("POST", p(`/users/${String(id)}/recovery`), parseTokenResponse, { body: r }),
|
|
329
|
+
adminInvite: (r) => json("POST", p("/invites"), parseTokenResponse, { body: r }),
|
|
330
|
+
adminTokens: () => json("GET", p("/tokens"), tokenList),
|
|
331
|
+
adminKeys: () => json("GET", p("/admin/apikeys"), keyList),
|
|
332
|
+
adminRevokeKey: (id) => empty("DELETE", p(`/admin/apikeys/${String(id)}`)),
|
|
333
|
+
adminSecurityLog: (q) =>
|
|
334
|
+
json(
|
|
335
|
+
"GET",
|
|
336
|
+
p(
|
|
337
|
+
`/security-log${query({
|
|
338
|
+
user: num(q?.user),
|
|
339
|
+
actor: num(q?.actor),
|
|
340
|
+
actor_key: num(q?.actorKey),
|
|
341
|
+
after: num(q?.after),
|
|
342
|
+
before: num(q?.before),
|
|
343
|
+
kinds: q?.kinds === undefined || q.kinds.length === 0 ? undefined : q.kinds.join(","),
|
|
344
|
+
limit: num(q?.limit),
|
|
345
|
+
oldest_first: q?.oldestFirst === true ? "true" : undefined,
|
|
346
|
+
})}`,
|
|
347
|
+
),
|
|
348
|
+
parseSecurityLogView,
|
|
349
|
+
),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readOIDCReturn } from "./oidc.js";
|
|
2
|
+
|
|
3
|
+
const PREFIX = "@cplieger/access:continue:";
|
|
4
|
+
const NONCE = /^[A-Za-z0-9_-]{22}$/;
|
|
5
|
+
|
|
6
|
+
function storage(): Storage | undefined {
|
|
7
|
+
try {
|
|
8
|
+
return (globalThis as { readonly sessionStorage?: Storage }).sessionStorage;
|
|
9
|
+
} catch {
|
|
10
|
+
// The getter itself throws SecurityError where storage is blocked
|
|
11
|
+
// (https://html.spec.whatwg.org/multipage/webstorage.html#dom-sessionstorage).
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nonce(): string {
|
|
17
|
+
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
18
|
+
return btoa(String.fromCharCode(...b))
|
|
19
|
+
.replaceAll("+", "-")
|
|
20
|
+
.replaceAll("/", "_")
|
|
21
|
+
.replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
25
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Undefined for an entry this module did not write.
|
|
29
|
+
function entry(raw: string | null): { readonly exp: number; readonly v: unknown } | undefined {
|
|
30
|
+
if (raw === null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
let e: unknown;
|
|
34
|
+
try {
|
|
35
|
+
e = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(e) || typeof e["exp"] !== "number" || !Object.hasOwn(e, "v")) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { exp: e["exp"], v: e["v"] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dropExpired(s: Storage, now: number): void {
|
|
46
|
+
const keys: string[] = [];
|
|
47
|
+
for (let i = 0; i < s.length; i++) {
|
|
48
|
+
const k = s.key(i);
|
|
49
|
+
if (k?.startsWith(PREFIX) === true) {
|
|
50
|
+
keys.push(k);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const k of keys) {
|
|
54
|
+
const e = entry(s.getItem(k));
|
|
55
|
+
if (e === undefined || now > e.exp) {
|
|
56
|
+
s.removeItem(k);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The same-origin half of an OIDC step-up's continuation, kept per tab in sessionStorage. */
|
|
62
|
+
export const continuation: {
|
|
63
|
+
/**
|
|
64
|
+
* Stores value (JSON-serialisable) under a fresh 128-bit base64url nonce for at most
|
|
65
|
+
* maxAgeMs, and returns the nonce, which the app puts in its startOIDC redirect as
|
|
66
|
+
* continue=<nonce>. Throws, storing nothing, unless maxAgeMs is a finite number greater than
|
|
67
|
+
* 0 and JSON.stringify can serialise value.
|
|
68
|
+
*/
|
|
69
|
+
put(value: unknown, maxAgeMs: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* Reads search (location.search). Returns the stored value once, only when the oidc marker
|
|
72
|
+
* is stepped_up and continue names a known, unspent, unexpired nonce; otherwise undefined.
|
|
73
|
+
* Every call that names a nonce spends it, whatever it returns, and drops every expired
|
|
74
|
+
* entry.
|
|
75
|
+
*/
|
|
76
|
+
take(search: string): unknown;
|
|
77
|
+
} = {
|
|
78
|
+
put(value, maxAgeMs) {
|
|
79
|
+
if (!(Number.isFinite(maxAgeMs) && maxAgeMs > 0)) {
|
|
80
|
+
throw new RangeError("continuation.put: maxAgeMs must be a finite number above 0");
|
|
81
|
+
}
|
|
82
|
+
if ((JSON.stringify(value) as string | undefined) === undefined) {
|
|
83
|
+
throw new TypeError("continuation.put: value has no JSON form");
|
|
84
|
+
}
|
|
85
|
+
const record = JSON.stringify({ v: value, exp: Date.now() + maxAgeMs });
|
|
86
|
+
const s = storage();
|
|
87
|
+
if (s === undefined) {
|
|
88
|
+
throw new Error("continuation.put: sessionStorage is unavailable");
|
|
89
|
+
}
|
|
90
|
+
const n = nonce();
|
|
91
|
+
s.setItem(PREFIX + n, record);
|
|
92
|
+
return n;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
take(search) {
|
|
96
|
+
const s = storage();
|
|
97
|
+
if (s === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const named = new URLSearchParams(search).getAll("continue");
|
|
102
|
+
const found = named.map((n) => {
|
|
103
|
+
const k = PREFIX + n;
|
|
104
|
+
const e = entry(s.getItem(k));
|
|
105
|
+
s.removeItem(k);
|
|
106
|
+
return e;
|
|
107
|
+
});
|
|
108
|
+
dropExpired(s, now);
|
|
109
|
+
const [only] = named;
|
|
110
|
+
const [e] = found;
|
|
111
|
+
if (
|
|
112
|
+
named.length !== 1 ||
|
|
113
|
+
only === undefined ||
|
|
114
|
+
!NONCE.test(only) ||
|
|
115
|
+
e === undefined ||
|
|
116
|
+
now > e.exp ||
|
|
117
|
+
readOIDCReturn(search).kind !== "stepped_up"
|
|
118
|
+
) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return e.v;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function utf8Length(s: string): number {
|
|
126
|
+
return new TextEncoder().encode(s).length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decode(s: string): string | undefined {
|
|
130
|
+
try {
|
|
131
|
+
return decodeURIComponent(s);
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reads name from location.hash's key=value pairs, removes every pair named name with
|
|
139
|
+
* history.replaceState (and the "#" when none is left), and returns the value when name
|
|
140
|
+
* appears exactly once and its value is non-empty and at most maxBytes UTF-8 bytes; otherwise
|
|
141
|
+
* undefined. The pairs are removed in every case, so no token stays in the URL or in this
|
|
142
|
+
* history entry; an entry the browser recorded elsewhere before the call is out of its reach.
|
|
143
|
+
*/
|
|
144
|
+
export function takeFragmentToken(name: string, maxBytes = 512): string | undefined {
|
|
145
|
+
const hash = globalThis.location.hash;
|
|
146
|
+
if (hash.length <= 1) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const pairs = hash.slice(1).split("&");
|
|
150
|
+
const values: (string | undefined)[] = [];
|
|
151
|
+
const kept: string[] = [];
|
|
152
|
+
for (const p of pairs) {
|
|
153
|
+
const eq = p.indexOf("=");
|
|
154
|
+
const key = eq === -1 ? p : p.slice(0, eq);
|
|
155
|
+
if (decode(key) === name) {
|
|
156
|
+
values.push(eq === -1 ? "" : decode(p.slice(eq + 1)));
|
|
157
|
+
} else {
|
|
158
|
+
kept.push(p);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (values.length === 0) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const { pathname, search } = globalThis.location;
|
|
165
|
+
history.replaceState(
|
|
166
|
+
history.state,
|
|
167
|
+
"",
|
|
168
|
+
pathname + search + (kept.length === 0 ? "" : `#${kept.join("&")}`),
|
|
169
|
+
);
|
|
170
|
+
const [v] = values;
|
|
171
|
+
return values.length === 1 && v !== undefined && v !== "" && utf8Length(v) <= maxBytes
|
|
172
|
+
? v
|
|
173
|
+
: undefined;
|
|
174
|
+
}
|