@cplieger/access 0.0.0-stage → 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/LICENSE +201 -0
- package/NOTICE +3 -0
- package/README.md +65 -2
- package/jsr.json +22 -0
- package/package.json +60 -4
- 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/src/oidc.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ApiResult } from "@cplieger/reactive/fetch";
|
|
2
|
+
import type { AuthClient } from "./client.js";
|
|
3
|
+
import { knownCode } from "./wire.js";
|
|
4
|
+
import type { AuthCode, MeResponse, OIDCStartResponse, PendingLinkResponse } from "./wire.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The URL that starts an OIDC sign-in, for a plain navigation or a link: GET
|
|
8
|
+
* {oidcPrefix}/start/signin, with redirect, the app's relative return path, when given. The
|
|
9
|
+
* server lands on "/" for a redirect it refuses.
|
|
10
|
+
*/
|
|
11
|
+
export function oidcSignInURL(c: AuthClient, redirect?: string): string {
|
|
12
|
+
const start = `${c.oidcPrefix}/start/signin`;
|
|
13
|
+
return redirect === undefined || redirect === ""
|
|
14
|
+
? start
|
|
15
|
+
: `${start}?${new URLSearchParams({ redirect }).toString()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Link and step-up POST same-origin first, then navigate to the returned URL. redirect is the
|
|
20
|
+
* app's relative return path, at most 512 bytes (oidc.MaxRedirectBytes); the server refuses a
|
|
21
|
+
* longer one.
|
|
22
|
+
*/
|
|
23
|
+
export function startOIDC(
|
|
24
|
+
c: AuthClient,
|
|
25
|
+
intent: "link" | "stepup",
|
|
26
|
+
redirect?: string,
|
|
27
|
+
): Promise<ApiResult<OIDCStartResponse>> {
|
|
28
|
+
const r = redirect === undefined ? {} : { redirect };
|
|
29
|
+
return intent === "link" ? c.oidcStartLink(r) : c.oidcStartStepUp(r);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type OIDCReturn =
|
|
33
|
+
| { readonly kind: "none" }
|
|
34
|
+
| { readonly kind: "not_linked" }
|
|
35
|
+
| { readonly kind: "linked" }
|
|
36
|
+
| { readonly kind: "stepped_up" }
|
|
37
|
+
| { readonly kind: "error"; readonly code: AuthCode | undefined };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads the callback's markers from search (location.search). An auth_error outside the
|
|
41
|
+
* AuthCode union gives code undefined, and an auth_error beside an oidc marker is the error.
|
|
42
|
+
* A marker is a hint any link can carry: the page never dispatches on it without the user's
|
|
43
|
+
* click, and a lasting continuation is bound to same-origin state, continuation.put's nonce,
|
|
44
|
+
* never to URL arguments alone.
|
|
45
|
+
*/
|
|
46
|
+
export function readOIDCReturn(search: string): OIDCReturn {
|
|
47
|
+
const q = new URLSearchParams(search);
|
|
48
|
+
const errors = q.getAll("auth_error");
|
|
49
|
+
if (errors.length > 0) {
|
|
50
|
+
const only = errors.length === 1 ? errors[0] : undefined;
|
|
51
|
+
return { kind: "error", code: only === undefined ? undefined : knownCode(only) };
|
|
52
|
+
}
|
|
53
|
+
const markers = q.getAll("oidc");
|
|
54
|
+
if (markers.length !== 1) {
|
|
55
|
+
return { kind: "none" };
|
|
56
|
+
}
|
|
57
|
+
switch (markers[0]) {
|
|
58
|
+
case "not_linked":
|
|
59
|
+
return { kind: "not_linked" };
|
|
60
|
+
case "linked":
|
|
61
|
+
return { kind: "linked" };
|
|
62
|
+
case "stepped_up":
|
|
63
|
+
return { kind: "stepped_up" };
|
|
64
|
+
default:
|
|
65
|
+
return { kind: "none" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The IdP identity a not-linked OIDC sign-in left in its pending-link cookie. */
|
|
70
|
+
export function pendingLink(c: AuthClient): Promise<ApiResult<PendingLinkResponse>> {
|
|
71
|
+
return c.oidcPending();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Links the pending IdP identity to the signed-in user and returns the updated user. */
|
|
75
|
+
export function completePendingLink(c: AuthClient): Promise<ApiResult<MeResponse>> {
|
|
76
|
+
return c.oidcLinkPending();
|
|
77
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Chromium-only and absent from TypeScript's DOM lib; its instances are plain Credentials,
|
|
2
|
+
// which navigator.credentials.store accepts.
|
|
3
|
+
// https://w3c.github.io/webappsec-credential-management/#passwordcredential
|
|
4
|
+
type PasswordCredentialCtor = new (data: { id: string; password: string }) => Credential;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asks the browser's password manager to save username and password after a form that
|
|
8
|
+
* submits over fetch, where no navigation follows for its own save heuristic to see. Does
|
|
9
|
+
* nothing where PasswordCredential is missing, for an empty username or password, and when
|
|
10
|
+
* the browser declines; never rejects.
|
|
11
|
+
*/
|
|
12
|
+
export async function storePasswordCredential(username: string, password: string): Promise<void> {
|
|
13
|
+
if (username === "" || password === "") {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const g = globalThis as {
|
|
17
|
+
readonly PasswordCredential?: PasswordCredentialCtor;
|
|
18
|
+
readonly navigator?: { readonly credentials?: CredentialsContainer };
|
|
19
|
+
};
|
|
20
|
+
const ctor = g.PasswordCredential;
|
|
21
|
+
const store = g.navigator?.credentials;
|
|
22
|
+
if (typeof ctor !== "function" || store === undefined) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await store.store(new ctor({ id: username, password }));
|
|
27
|
+
} catch {
|
|
28
|
+
// A declined, blocked or unimplemented store is not the caller's failure.
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/secret.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { signal } from "@cplieger/reactive/signal";
|
|
2
|
+
import type { ReadonlySignal } from "@cplieger/reactive/signal";
|
|
3
|
+
|
|
4
|
+
/** A secret shown to the user once, such as a new API key or a recovery link. */
|
|
5
|
+
export interface ShownOnce {
|
|
6
|
+
/** The plaintext until it is cleared, then null for good. */
|
|
7
|
+
readonly value: ReadonlySignal<string | null>;
|
|
8
|
+
/** Clears the value now; later calls do nothing. */
|
|
9
|
+
dismiss(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Holds plaintext for display and clears it on dismiss, on the page's pagehide (so a page
|
|
14
|
+
* restored from the back-forward cache shows nothing), and after clearAfterMs when given.
|
|
15
|
+
* Throws unless clearAfterMs, when given, is a finite number greater than 0.
|
|
16
|
+
*/
|
|
17
|
+
export function shownOnce(plaintext: string, o?: { readonly clearAfterMs?: number }): ShownOnce {
|
|
18
|
+
const after = o?.clearAfterMs;
|
|
19
|
+
if (after !== undefined && !(Number.isFinite(after) && after > 0)) {
|
|
20
|
+
throw new RangeError(`shownOnce: clearAfterMs must be a finite number above 0, got ${after}`);
|
|
21
|
+
}
|
|
22
|
+
const value = signal<string | null>(plaintext);
|
|
23
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
+
const dismiss = (): void => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
globalThis.removeEventListener("pagehide", dismiss);
|
|
27
|
+
value.value = null;
|
|
28
|
+
};
|
|
29
|
+
globalThis.addEventListener("pagehide", dismiss);
|
|
30
|
+
if (after !== undefined) {
|
|
31
|
+
timer = setTimeout(dismiss, after);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
value: {
|
|
35
|
+
get value() {
|
|
36
|
+
return value.value;
|
|
37
|
+
},
|
|
38
|
+
peek: () => value.peek(),
|
|
39
|
+
},
|
|
40
|
+
dismiss,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ApiResult } from "@cplieger/reactive/fetch";
|
|
2
|
+
import { signal } from "@cplieger/reactive/signal";
|
|
3
|
+
import type { ReadonlySignal } from "@cplieger/reactive/signal";
|
|
4
|
+
import type { AuthClient } from "./client.js";
|
|
5
|
+
import type { SessionView } from "./wire.js";
|
|
6
|
+
|
|
7
|
+
/** The signed-in user's device list and the calls that change it. */
|
|
8
|
+
export interface SessionsModel {
|
|
9
|
+
/** Empty until the first successful refresh. */
|
|
10
|
+
readonly sessions: ReadonlySignal<readonly SessionView[]>;
|
|
11
|
+
/** Replaces the list with the server's; a refusal leaves the list as it was. */
|
|
12
|
+
refresh(): Promise<ApiResult<void>>;
|
|
13
|
+
revoke(id: string): Promise<ApiResult<void>>;
|
|
14
|
+
revokeOthers(): Promise<ApiResult<void>>;
|
|
15
|
+
/** An empty label clears the session's label. */
|
|
16
|
+
rename(id: string, label: string): Promise<ApiResult<void>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A device-list model over c. Each change applies to the list only after the server accepts
|
|
21
|
+
* it, by the rule the server applied, so the list needs no refresh after a change.
|
|
22
|
+
*/
|
|
23
|
+
export function createSessionsModel(c: AuthClient): SessionsModel {
|
|
24
|
+
const list = signal<readonly SessionView[]>([]);
|
|
25
|
+
const after = async (
|
|
26
|
+
call: Promise<ApiResult<void>>,
|
|
27
|
+
apply: (s: readonly SessionView[]) => readonly SessionView[],
|
|
28
|
+
): Promise<ApiResult<void>> => {
|
|
29
|
+
const res = await call;
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
list.value = apply(list.peek());
|
|
32
|
+
}
|
|
33
|
+
return res;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
sessions: {
|
|
37
|
+
get value() {
|
|
38
|
+
return list.value;
|
|
39
|
+
},
|
|
40
|
+
peek: () => list.peek(),
|
|
41
|
+
},
|
|
42
|
+
refresh: async () => {
|
|
43
|
+
const res = await c.sessions();
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
return res;
|
|
46
|
+
}
|
|
47
|
+
list.value = res.data;
|
|
48
|
+
return { ...res, data: undefined };
|
|
49
|
+
},
|
|
50
|
+
revoke: (id) => after(c.revokeSession(id), (s) => s.filter((x) => x.id !== id)),
|
|
51
|
+
revokeOthers: () => after(c.revokeOtherSessions(), (s) => s.filter((x) => x.current)),
|
|
52
|
+
rename: (id, label) =>
|
|
53
|
+
after(c.renameSession(id, { name: label }), (s) =>
|
|
54
|
+
s.map((x) => {
|
|
55
|
+
if (x.id !== id) {
|
|
56
|
+
return x;
|
|
57
|
+
}
|
|
58
|
+
const { label: _old, ...rest } = x;
|
|
59
|
+
return label === "" ? rest : { ...rest, label };
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/stepup.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ApiErr, ApiResult } from "@cplieger/reactive/fetch";
|
|
2
|
+
import type { AuthClient } from "./client.js";
|
|
3
|
+
import { assign } from "./navigate.js";
|
|
4
|
+
import { startOIDC } from "./oidc.js";
|
|
5
|
+
import { stepUpWithPasskey } from "./webauthn.js";
|
|
6
|
+
import type { StepUpOutcome } from "./webauthn.js";
|
|
7
|
+
import { authCode, errorReason } from "./wire.js";
|
|
8
|
+
import type { MeResponse } from "./wire.js";
|
|
9
|
+
|
|
10
|
+
/** Asks the user how to re-authenticate, given the methods the account holds. */
|
|
11
|
+
export type StepUpPrompt = (
|
|
12
|
+
methods: MeResponse["methods"],
|
|
13
|
+
) => Promise<"password" | "passkey" | "oidc" | "cancel">;
|
|
14
|
+
|
|
15
|
+
export interface StepUpOptions {
|
|
16
|
+
readonly prompt: StepUpPrompt;
|
|
17
|
+
/** The user's current password, or null when the user cancels. */
|
|
18
|
+
readonly password: () => Promise<string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Where an OIDC step-up returns: the app's own relative path, at most 512 bytes, in which
|
|
21
|
+
* the app encodes what to continue. Default location.pathname + location.search, or
|
|
22
|
+
* location.pathname, or "/", the first that fits 512 bytes counted in UTF-8.
|
|
23
|
+
*/
|
|
24
|
+
readonly redirect?: string;
|
|
25
|
+
/** Default location.assign. */
|
|
26
|
+
readonly navigate?: (url: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type StepUpResult<T> =
|
|
30
|
+
/** The action's own result, after at most one retry. */
|
|
31
|
+
| { readonly kind: "done"; readonly result: ApiResult<T> }
|
|
32
|
+
| { readonly kind: "cancelled" }
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "step-up-failed";
|
|
35
|
+
readonly error: ApiErr | Exclude<StepUpOutcome, { kind: "stepped-up" }>;
|
|
36
|
+
}
|
|
37
|
+
/** OIDC: the page is leaving; nothing was retried. */
|
|
38
|
+
| { readonly kind: "redirecting" };
|
|
39
|
+
|
|
40
|
+
const MAX_REDIRECT_BYTES = 512;
|
|
41
|
+
|
|
42
|
+
function fits(path: string): boolean {
|
|
43
|
+
return new TextEncoder().encode(path).length <= MAX_REDIRECT_BYTES;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultRedirect(): string {
|
|
47
|
+
const { pathname, search } = globalThis.location;
|
|
48
|
+
if (fits(pathname + search)) {
|
|
49
|
+
return pathname + search;
|
|
50
|
+
}
|
|
51
|
+
return fits(pathname) ? pathname : "/";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs action; on an auth_recent_auth_required refusal with no reason, asks prompt for a
|
|
56
|
+
* method, re-authenticates and retries action once. A refusal with a reason (such as
|
|
57
|
+
* reenable_needs_other_method) is one a step-up cannot cure, so it is returned as done. The
|
|
58
|
+
* OIDC branch navigates away and never re-runs action: the app continues from its own
|
|
59
|
+
* redirect. Keeps nothing in browser storage and sends no request of its own beyond the
|
|
60
|
+
* client's me and step-up routes.
|
|
61
|
+
*/
|
|
62
|
+
export async function withStepUp<T>(
|
|
63
|
+
c: AuthClient,
|
|
64
|
+
action: () => Promise<ApiResult<T>>,
|
|
65
|
+
o: StepUpOptions,
|
|
66
|
+
): Promise<StepUpResult<T>> {
|
|
67
|
+
const first = await action();
|
|
68
|
+
if (
|
|
69
|
+
first.ok ||
|
|
70
|
+
authCode(first) !== "auth_recent_auth_required" ||
|
|
71
|
+
errorReason(first) !== undefined
|
|
72
|
+
) {
|
|
73
|
+
return { kind: "done", result: first };
|
|
74
|
+
}
|
|
75
|
+
const me = await c.me();
|
|
76
|
+
if (!me.ok) {
|
|
77
|
+
return { kind: "step-up-failed", error: me };
|
|
78
|
+
}
|
|
79
|
+
const retry = async (): Promise<StepUpResult<T>> => ({ kind: "done", result: await action() });
|
|
80
|
+
const choice = await o.prompt(me.data.methods);
|
|
81
|
+
switch (choice) {
|
|
82
|
+
case "cancel":
|
|
83
|
+
return { kind: "cancelled" };
|
|
84
|
+
case "password": {
|
|
85
|
+
const password = await o.password();
|
|
86
|
+
if (password === null) {
|
|
87
|
+
return { kind: "cancelled" };
|
|
88
|
+
}
|
|
89
|
+
const res = await c.stepUpPassword({ password });
|
|
90
|
+
return res.ok ? retry() : { kind: "step-up-failed", error: res };
|
|
91
|
+
}
|
|
92
|
+
case "passkey": {
|
|
93
|
+
const out = await stepUpWithPasskey(c);
|
|
94
|
+
switch (out.kind) {
|
|
95
|
+
case "stepped-up":
|
|
96
|
+
return retry();
|
|
97
|
+
case "cancelled":
|
|
98
|
+
return { kind: "cancelled" };
|
|
99
|
+
default:
|
|
100
|
+
return { kind: "step-up-failed", error: out };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
case "oidc": {
|
|
104
|
+
const res = await startOIDC(c, "stepup", o.redirect ?? defaultRedirect());
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
return { kind: "step-up-failed", error: res };
|
|
107
|
+
}
|
|
108
|
+
(o.navigate ?? assign)(res.data.url);
|
|
109
|
+
return { kind: "redirecting" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|