@samebase/identity-broker 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/dist/cli/index.js +18 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/client/html.d.ts +5 -1
- package/dist/client/html.js +14 -7
- package/dist/client/html.js.map +1 -1
- package/dist/client/index.d.ts +16 -1
- package/dist/client/index.js +136 -38
- package/dist/client/index.js.map +1 -1
- package/dist/client/keys.d.ts +5 -0
- package/dist/client/keys.js +8 -0
- package/dist/client/keys.js.map +1 -1
- package/dist/client/protocol.d.ts +5 -1
- package/dist/client/protocol.js +30 -10
- package/dist/client/protocol.js.map +1 -1
- package/dist/client/responses.d.ts +1 -1
- package/dist/client/responses.js +2 -2
- package/dist/client/responses.js.map +1 -1
- package/dist/component/_generated/component.d.ts +24 -1
- package/dist/component/authRequests.d.ts +24 -4
- package/dist/component/authRequests.js +77 -8
- package/dist/component/authRequests.js.map +1 -1
- package/dist/component/lifecycle.d.ts +6 -3
- package/dist/component/lifecycle.js +23 -3
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/registration.d.ts +12 -0
- package/dist/component/registration.js +33 -0
- package/dist/component/registration.js.map +1 -1
- package/dist/component/schema.d.ts +109 -10
- package/dist/component/schema.js +20 -2
- package/dist/component/schema.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ registers each deployment as a client at deploy time and never touches GitHub or
|
|
|
18
18
|
your own action. `github()`, `google()` and `email()` are included.
|
|
19
19
|
- A hub is a small Convex app that mounts the component. Fork
|
|
20
20
|
[samebase/identity-hub](https://github.com/samebase/identity-hub) for one.
|
|
21
|
+
- `@samebase/alchemy-identity-hub`: an Alchemy v2 resource, `IdentityHub.Client`, that registers a
|
|
22
|
+
deployment as a client at deploy time. See
|
|
23
|
+
[packages/alchemy-identity-hub](https://github.com/samebase/identity-broker/tree/main/packages/alchemy-identity-hub).
|
|
21
24
|
|
|
22
25
|
## Mount it
|
|
23
26
|
|
|
@@ -62,12 +65,23 @@ export default http;
|
|
|
62
65
|
```ts
|
|
63
66
|
// convex/identityBroker.ts: administration for `npx convex run`
|
|
64
67
|
import { broker } from "./http";
|
|
65
|
-
export const {
|
|
66
|
-
|
|
68
|
+
export const {
|
|
69
|
+
createApp,
|
|
70
|
+
mintRegistrationToken,
|
|
71
|
+
listRegistrationTokens,
|
|
72
|
+
revokeRegistrationToken,
|
|
73
|
+
setAllowedCallbackHosts,
|
|
74
|
+
deleteClient,
|
|
75
|
+
deleteApp,
|
|
76
|
+
listApps,
|
|
77
|
+
listClients,
|
|
78
|
+
cleanup,
|
|
79
|
+
} = broker.admin();
|
|
67
80
|
```
|
|
68
81
|
|
|
69
82
|
Keys: `npx identity-broker keys` prints the value for `IDENTITY_BROKER_KEYS`; `keys --rotate`
|
|
70
|
-
advances it.
|
|
83
|
+
advances it, and `keys --rotate --no-overlap` replaces a compromised key at once. Back up the key
|
|
84
|
+
set: its salt makes every `sub`. `parseKeySet` checks a value before a deploy.
|
|
71
85
|
|
|
72
86
|
## Register an app's deployments
|
|
73
87
|
|
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createKeySet, parseKeySet, rotateKeySet } from "../client/keys.js";
|
|
3
|
-
const USAGE = `identity-broker keys
|
|
4
|
-
identity-broker keys --rotate
|
|
2
|
+
import { createKeySet, parseKeySet, rotateKeySet, rotateKeySetWithoutOverlap, } from "../client/keys.js";
|
|
3
|
+
const USAGE = `identity-broker keys print a new key set for IDENTITY_BROKER_KEYS
|
|
4
|
+
identity-broker keys --rotate read the current key set on stdin, print the rotated
|
|
5
|
+
one; the retired public key stays for 14 days
|
|
6
|
+
identity-broker keys --rotate --no-overlap the same after a key compromise: retired keys are
|
|
7
|
+
dropped at once; the salt is kept`;
|
|
5
8
|
async function readStdin() {
|
|
6
9
|
const chunks = [];
|
|
7
10
|
for await (const chunk of process.stdin) {
|
|
@@ -14,8 +17,18 @@ async function main(argv) {
|
|
|
14
17
|
console.error(USAGE);
|
|
15
18
|
return 2;
|
|
16
19
|
}
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const rotate = argv.includes("--rotate");
|
|
21
|
+
const noOverlap = argv.includes("--no-overlap");
|
|
22
|
+
// Without --rotate, --no-overlap would print a new key set with a new salt: every sub changes.
|
|
23
|
+
if (noOverlap && !rotate) {
|
|
24
|
+
console.error(USAGE);
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
if (rotate) {
|
|
28
|
+
const current = parseKeySet(await readStdin());
|
|
29
|
+
const rotated = noOverlap
|
|
30
|
+
? await rotateKeySetWithoutOverlap(current)
|
|
31
|
+
: await rotateKeySet(current);
|
|
19
32
|
process.stdout.write(`${JSON.stringify(rotated)}\n`);
|
|
20
33
|
return 0;
|
|
21
34
|
}
|
package/dist/cli/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EACL,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,0BAA0B,GAC3B,MAAM,mBAAmB,CAAC;AAE3B,MAAM,KAAK,GAAG;;;;+EAIiE,CAAC;AAEhF,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,IAAc;IAChC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;QACvB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IAChD,+FAA+F;IAC/F,IAAI,SAAS,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;QAC/C,MAAM,OAAO,GAAG,SAAS;YACvB,CAAC,CAAC,MAAM,0BAA0B,CAAC,OAAO,CAAC;YAC3C,CAAC,CAAC,MAAM,YAAY,CAAC,OAAO,CAAC,CAAC;QAChC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACrD,OAAO,CAAC,CAAC;IACX,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,YAAY,EAAE,CAAC,IAAI,CAAC,CAAC;IAClE,OAAO,CAAC,CAAC;AACX,CAAC;AAED,OAAO,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC"}
|
package/dist/client/html.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The few pages the broker renders itself. Plain HTML, one stylesheet, no script. */
|
|
2
|
-
export declare function page(title: string, body: string): Response;
|
|
2
|
+
export declare function page(title: string, body: string, status?: number): Response;
|
|
3
3
|
/**
|
|
4
4
|
* The connector choice. A third-party app gets the consent wording and a deny link; a first-party
|
|
5
5
|
* app only sees the buttons.
|
|
@@ -7,6 +7,8 @@ export declare function page(title: string, body: string): Response;
|
|
|
7
7
|
export declare function chooserPage(args: {
|
|
8
8
|
appName: string;
|
|
9
9
|
firstParty: boolean;
|
|
10
|
+
/** The granted scopes: the consent names only what they release. */
|
|
11
|
+
scopes: string[];
|
|
10
12
|
connectors: Array<{
|
|
11
13
|
name: string;
|
|
12
14
|
label: string;
|
|
@@ -19,6 +21,7 @@ export declare function emailAddressPage(args: {
|
|
|
19
21
|
action: string;
|
|
20
22
|
requestId: string;
|
|
21
23
|
error?: string;
|
|
24
|
+
status?: number;
|
|
22
25
|
}): Response;
|
|
23
26
|
export declare function emailCodePage(args: {
|
|
24
27
|
appName: string;
|
|
@@ -26,5 +29,6 @@ export declare function emailCodePage(args: {
|
|
|
26
29
|
requestId: string;
|
|
27
30
|
email: string;
|
|
28
31
|
error?: string;
|
|
32
|
+
status?: number;
|
|
29
33
|
}): Response;
|
|
30
34
|
export declare function errorPage(title: string, message: string, status?: number): Response;
|
package/dist/client/html.js
CHANGED
|
@@ -20,7 +20,7 @@ const STYLE = `
|
|
|
20
20
|
.error { color: #b00020; }
|
|
21
21
|
.muted { color: #555; }
|
|
22
22
|
`;
|
|
23
|
-
export function page(title, body) {
|
|
23
|
+
export function page(title, body, status = 200) {
|
|
24
24
|
const html = `<!doctype html>
|
|
25
25
|
<html lang="en">
|
|
26
26
|
<head>
|
|
@@ -33,7 +33,7 @@ export function page(title, body) {
|
|
|
33
33
|
<body><main>${body}</main></body>
|
|
34
34
|
</html>`;
|
|
35
35
|
return new Response(html, {
|
|
36
|
-
status
|
|
36
|
+
status,
|
|
37
37
|
headers: {
|
|
38
38
|
"Content-Type": "text/html; charset=utf-8",
|
|
39
39
|
"Cache-Control": "no-store",
|
|
@@ -49,9 +49,17 @@ export function chooserPage(args) {
|
|
|
49
49
|
const buttons = args.connectors
|
|
50
50
|
.map((connector) => `<a class="button" href="${escape(connector.href)}">Continue with ${escape(connector.label)}</a>`)
|
|
51
51
|
.join("\n");
|
|
52
|
+
const released = [
|
|
53
|
+
"a stable id",
|
|
54
|
+
...(args.scopes.includes("profile") ? ["your name and picture"] : []),
|
|
55
|
+
...(args.scopes.includes("email") ? ["your verified email address"] : []),
|
|
56
|
+
];
|
|
57
|
+
const list = released.length <= 2
|
|
58
|
+
? released.join(" and ")
|
|
59
|
+
: `${released.slice(0, -1).join(", ")}, and ${released.slice(-1).join("")}`;
|
|
52
60
|
const consent = args.firstParty
|
|
53
61
|
? ""
|
|
54
|
-
: `<p class="muted">${escape(args.appName)} will receive your identity from the provider you choose:
|
|
62
|
+
: `<p class="muted">${escape(args.appName)} will receive your identity from the provider you choose: ${list}.</p>`;
|
|
55
63
|
const deny = args.firstParty
|
|
56
64
|
? ""
|
|
57
65
|
: `<p><a href="${escape(args.denyHref)}">Cancel and return to ${escape(args.appName)}</a></p>`;
|
|
@@ -65,7 +73,7 @@ export function emailAddressPage(args) {
|
|
|
65
73
|
<input type="hidden" name="step" value="send">
|
|
66
74
|
<input type="email" name="email" required autocomplete="email" autofocus placeholder="you@example.com">
|
|
67
75
|
<button type="submit">Send code</button>
|
|
68
|
-
</form
|
|
76
|
+
</form>`, args.status);
|
|
69
77
|
}
|
|
70
78
|
export function emailCodePage(args) {
|
|
71
79
|
const error = args.error === undefined ? "" : `<p class="error">${escape(args.error)}</p>`;
|
|
@@ -75,10 +83,9 @@ export function emailCodePage(args) {
|
|
|
75
83
|
<input type="hidden" name="step" value="verify">
|
|
76
84
|
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" required autocomplete="one-time-code" autofocus placeholder="000000">
|
|
77
85
|
<button type="submit">Continue</button>
|
|
78
|
-
</form
|
|
86
|
+
</form>`, args.status);
|
|
79
87
|
}
|
|
80
88
|
export function errorPage(title, message, status = 400) {
|
|
81
|
-
|
|
82
|
-
return new Response(response.body, { status, headers: response.headers });
|
|
89
|
+
return page(title, `<h1>${escape(title)}</h1><p>${escape(message)}</p>`, status);
|
|
83
90
|
}
|
|
84
91
|
//# sourceMappingURL=html.js.map
|
package/dist/client/html.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/client/html.ts"],"names":[],"mappings":"AAAA,sFAAsF;AAEtF,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,KAAK,GAAG;;;;;;;;;;;;;CAab,CAAC;AAEF,MAAM,UAAU,IAAI,CAAC,KAAa,EAAE,IAAY;
|
|
1
|
+
{"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/client/html.ts"],"names":[],"mappings":"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"}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type { Connector, EmailConnector, OauthConnector, OidcConnector, SendEmai
|
|
|
5
5
|
export { github } from "./connectors/github.js";
|
|
6
6
|
export { google } from "./connectors/google.js";
|
|
7
7
|
export { email } from "./connectors/email.js";
|
|
8
|
-
export { createKeySet, rotateKeySet, type KeySet } from "./keys.js";
|
|
8
|
+
export { createKeySet, parseKeySet, rotateKeySet, type KeySet } from "./keys.js";
|
|
9
9
|
export type IdentityBrokerConfig = {
|
|
10
10
|
/** `components.identityBroker` of the host app. */
|
|
11
11
|
component: ComponentApi;
|
|
@@ -44,6 +44,19 @@ export declare function defineIdentityBroker(config: IdentityBrokerConfig): {
|
|
|
44
44
|
allowedCallbackHosts: string[];
|
|
45
45
|
appId: string;
|
|
46
46
|
}, Promise<null>>;
|
|
47
|
+
listRegistrationTokens: import("convex/server").RegisteredQuery<"internal", {
|
|
48
|
+
appId: string;
|
|
49
|
+
}, Promise<{
|
|
50
|
+
createdAt: number;
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
tokenHash: string;
|
|
53
|
+
}[]>>;
|
|
54
|
+
revokeRegistrationToken: import("convex/server").RegisteredMutation<"internal", {
|
|
55
|
+
tokenHash: string;
|
|
56
|
+
}, Promise<null>>;
|
|
57
|
+
deleteClient: import("convex/server").RegisteredMutation<"internal", {
|
|
58
|
+
clientId: string;
|
|
59
|
+
}, Promise<null>>;
|
|
47
60
|
deleteApp: import("convex/server").RegisteredMutation<"internal", {
|
|
48
61
|
appId: string;
|
|
49
62
|
}, Promise<null>>;
|
|
@@ -71,7 +84,9 @@ export declare function defineIdentityBroker(config: IdentityBrokerConfig): {
|
|
|
71
84
|
cleanup: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
72
85
|
authRequests: number;
|
|
73
86
|
authorizationCodes: number;
|
|
87
|
+
emailSends: number;
|
|
74
88
|
previewClients: number;
|
|
89
|
+
registrationTokens: number;
|
|
75
90
|
signIns: number;
|
|
76
91
|
}>>;
|
|
77
92
|
};
|
package/dist/client/index.js
CHANGED
|
@@ -11,13 +11,15 @@ import { pairwiseSubject, randomDigits, randomToken, sha256Hex } from "./subject
|
|
|
11
11
|
export { github } from "./connectors/github.js";
|
|
12
12
|
export { google } from "./connectors/google.js";
|
|
13
13
|
export { email } from "./connectors/email.js";
|
|
14
|
-
export { createKeySet, rotateKeySet } from "./keys.js";
|
|
14
|
+
export { createKeySet, parseKeySet, rotateKeySet } from "./keys.js";
|
|
15
15
|
/** A request lives this long from /authorize to the code redirect. */
|
|
16
16
|
const AUTH_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
17
17
|
/** A code is redeemed by the client's callback at once. */
|
|
18
18
|
const CODE_TTL_MS = 60 * 1000;
|
|
19
19
|
const EMAIL_CODE_TTL_MS = 10 * 60 * 1000;
|
|
20
20
|
const EMAIL_MAX_ATTEMPTS = 5;
|
|
21
|
+
/** The scopes the broker grants; any other requested scope is dropped. */
|
|
22
|
+
const SCOPES = ["openid", "email", "profile"];
|
|
21
23
|
const REGISTRATION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
24
|
/** Lifecycle values the host's cron passes to `cleanup`. */
|
|
23
25
|
export const PREVIEW_CLIENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -30,6 +32,9 @@ export function defineIdentityBroker(config) {
|
|
|
30
32
|
const issuer = issuerUrl.href.replace(/\/$/, "");
|
|
31
33
|
const prefix = issuerUrl.pathname.replace(/\/$/, "");
|
|
32
34
|
const path = (suffix) => `${prefix}${suffix}`;
|
|
35
|
+
/** Binds the chooser's links to its browser. `__Host-` needs Path=/, so a path issuer uses a plain name. */
|
|
36
|
+
const txnCookieName = prefix === "" ? "__Host-broker_txn" : "broker_txn";
|
|
37
|
+
const txnCookieAttributes = `HttpOnly; Secure; SameSite=Lax; Path=${prefix === "" ? "/" : prefix}`;
|
|
33
38
|
const component = config.component;
|
|
34
39
|
const connectors = new Map(config.connectors.map((connector) => [connector.name, connector]));
|
|
35
40
|
if (connectors.size !== config.connectors.length) {
|
|
@@ -47,7 +52,7 @@ export function defineIdentityBroker(config) {
|
|
|
47
52
|
userinfo_endpoint: `${issuer}/userinfo`,
|
|
48
53
|
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
49
54
|
registration_endpoint: `${issuer}/register`,
|
|
50
|
-
scopes_supported:
|
|
55
|
+
scopes_supported: SCOPES,
|
|
51
56
|
response_types_supported: ["code"],
|
|
52
57
|
response_modes_supported: ["query"],
|
|
53
58
|
grant_types_supported: ["authorization_code"],
|
|
@@ -165,6 +170,15 @@ export function defineIdentityBroker(config) {
|
|
|
165
170
|
});
|
|
166
171
|
}
|
|
167
172
|
const freshLogin = rest.value.prompt.includes("login") || rest.value.maxAge !== undefined;
|
|
173
|
+
const scopes = SCOPES.filter((scope) => rest.value.scopes.includes(scope));
|
|
174
|
+
// A first-party app may skip the chooser: the one it named, or the only one. A third-party app
|
|
175
|
+
// always gets the page, which doubles as consent (RFC 9700 4.11).
|
|
176
|
+
const named = rest.value.connector === undefined ? undefined : connectors.get(rest.value.connector);
|
|
177
|
+
const only = connectors.size === 1 ? [...connectors.values()][0] : undefined;
|
|
178
|
+
const chosen = app.firstParty ? (named ?? only) : undefined;
|
|
179
|
+
// The chooser's links work only with this cookie, so a client that fetches the chooser itself
|
|
180
|
+
// cannot send a user's browser on from it.
|
|
181
|
+
const txnCookie = randomToken(32);
|
|
168
182
|
const now = Date.now();
|
|
169
183
|
const requestId = randomToken(24);
|
|
170
184
|
await ctx.runMutation(component.authRequests.create, {
|
|
@@ -172,34 +186,32 @@ export function defineIdentityBroker(config) {
|
|
|
172
186
|
appId: app.appId,
|
|
173
187
|
clientId: client.clientId,
|
|
174
188
|
redirectUri: client.redirectUri,
|
|
175
|
-
scope:
|
|
189
|
+
scope: scopes.join(" "),
|
|
176
190
|
...(rest.value.state === undefined ? {} : { state: rest.value.state }),
|
|
177
191
|
...(rest.value.nonce === undefined ? {} : { nonce: rest.value.nonce }),
|
|
178
192
|
codeChallenge: rest.value.codeChallenge,
|
|
179
193
|
freshLogin,
|
|
194
|
+
...(chosen === undefined ? { txnCookieHash: await sha256Hex(txnCookie) } : {}),
|
|
180
195
|
createdAt: now,
|
|
181
196
|
expiresAt: now + AUTH_REQUEST_TTL_MS,
|
|
182
197
|
});
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const chosen = named ?? only;
|
|
196
|
-
if (chosen !== undefined) {
|
|
197
|
-
return await startConnector(ctx, { connector: chosen, request: stored, appName: app.name });
|
|
198
|
-
}
|
|
198
|
+
if (chosen !== undefined) {
|
|
199
|
+
return await startConnector(ctx, {
|
|
200
|
+
connector: chosen,
|
|
201
|
+
request: {
|
|
202
|
+
requestId,
|
|
203
|
+
appId: app.appId,
|
|
204
|
+
redirectUri: client.redirectUri,
|
|
205
|
+
state: rest.value.state,
|
|
206
|
+
freshLogin,
|
|
207
|
+
},
|
|
208
|
+
appName: app.name,
|
|
209
|
+
});
|
|
199
210
|
}
|
|
200
|
-
|
|
211
|
+
const page = chooserPage({
|
|
201
212
|
appName: app.name,
|
|
202
213
|
firstParty: app.firstParty,
|
|
214
|
+
scopes,
|
|
203
215
|
connectors: [...connectors.values()].map((connector) => ({
|
|
204
216
|
name: connector.name,
|
|
205
217
|
label: connector.label,
|
|
@@ -207,6 +219,8 @@ export function defineIdentityBroker(config) {
|
|
|
207
219
|
})),
|
|
208
220
|
denyHref: `${path("/continue")}?request=${encodeURIComponent(requestId)}&deny=1`,
|
|
209
221
|
});
|
|
222
|
+
page.headers.append("Set-Cookie", `${txnCookieName}=${txnCookie}; ${txnCookieAttributes}`);
|
|
223
|
+
return page;
|
|
210
224
|
});
|
|
211
225
|
/** A chooser click: start the chosen connector, or abandon the request on deny. */
|
|
212
226
|
const continueSignIn = httpActionGeneric(async (ctx, request) => {
|
|
@@ -216,6 +230,17 @@ export function defineIdentityBroker(config) {
|
|
|
216
230
|
if (stored === null) {
|
|
217
231
|
return errorPage("Sign-in not found", "This sign-in is unknown or finished. Return to the app and try again.", 404);
|
|
218
232
|
}
|
|
233
|
+
const txnCookie = request.headers
|
|
234
|
+
.get("cookie")
|
|
235
|
+
?.split(";")
|
|
236
|
+
.map((pair) => pair.trim())
|
|
237
|
+
.find((pair) => pair.startsWith(`${txnCookieName}=`))
|
|
238
|
+
?.slice(txnCookieName.length + 1);
|
|
239
|
+
if (stored.txnCookieHash === undefined ||
|
|
240
|
+
txnCookie === undefined ||
|
|
241
|
+
(await sha256Hex(txnCookie)) !== stored.txnCookieHash) {
|
|
242
|
+
return errorPage("Sign-in not continued", "This sign-in did not start in this browser. Return to the app and try again.", 400);
|
|
243
|
+
}
|
|
219
244
|
const back = { redirectUri: stored.redirectUri, issuer, state: stored.state };
|
|
220
245
|
if (url.searchParams.get("deny") !== null) {
|
|
221
246
|
await ctx.runMutation(component.authRequests.abandon, { requestId });
|
|
@@ -262,6 +287,7 @@ export function defineIdentityBroker(config) {
|
|
|
262
287
|
const now = Date.now();
|
|
263
288
|
const claim = await ctx.runMutation(component.authRequests.claimUpstream, {
|
|
264
289
|
upstreamStateHash: await sha256Hex(state),
|
|
290
|
+
connector: connector.name,
|
|
265
291
|
now,
|
|
266
292
|
});
|
|
267
293
|
if (claim.status === "missing") {
|
|
@@ -288,14 +314,24 @@ export function defineIdentityBroker(config) {
|
|
|
288
314
|
}
|
|
289
315
|
catch (error) {
|
|
290
316
|
await ctx.runMutation(component.authRequests.abandon, { requestId: claim.requestId });
|
|
291
|
-
|
|
317
|
+
// The provider's error code picks one fixed answer; nothing the provider wrote is echoed.
|
|
318
|
+
if (error instanceof oauth.AuthorizationResponseError && error.error === "access_denied") {
|
|
319
|
+
return authorizationError({
|
|
320
|
+
...back,
|
|
321
|
+
error: "access_denied",
|
|
322
|
+
description: `${connector.label} sign-in was declined.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (error instanceof oauth.AuthorizationResponseError &&
|
|
326
|
+
error.error === "temporarily_unavailable") {
|
|
292
327
|
return authorizationError({
|
|
293
328
|
...back,
|
|
294
|
-
error:
|
|
295
|
-
description: `${connector.label}
|
|
329
|
+
error: "temporarily_unavailable",
|
|
330
|
+
description: `${connector.label} is temporarily unavailable.`,
|
|
296
331
|
});
|
|
297
332
|
}
|
|
298
|
-
|
|
333
|
+
// Only the class name: provider errors carry token responses and decoded claims.
|
|
334
|
+
console.error(`Connector "${connector.name}" failed: ${error instanceof Error ? error.constructor.name : typeof error}`);
|
|
299
335
|
return authorizationError({
|
|
300
336
|
...back,
|
|
301
337
|
error: "server_error",
|
|
@@ -362,24 +398,59 @@ export function defineIdentityBroker(config) {
|
|
|
362
398
|
requestId,
|
|
363
399
|
connector: emailConnector.name,
|
|
364
400
|
email: address,
|
|
401
|
+
emailHash: await sha256Hex(address),
|
|
365
402
|
emailCodeHash: await sha256Hex(code),
|
|
366
403
|
emailCodeExpiresAt: now + EMAIL_CODE_TTL_MS,
|
|
404
|
+
maxAttempts: EMAIL_MAX_ATTEMPTS,
|
|
367
405
|
now,
|
|
368
406
|
});
|
|
369
407
|
if (!started.ok) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
408
|
+
switch (started.error) {
|
|
409
|
+
case "not_found":
|
|
410
|
+
return errorPage("Sign-in not found", "This sign-in is unknown. Return to the app and try again.", 404);
|
|
411
|
+
case "expired":
|
|
412
|
+
case "wrong_status":
|
|
413
|
+
return authorizationError({
|
|
414
|
+
...back,
|
|
415
|
+
error: "access_denied",
|
|
416
|
+
description: "The sign-in expired.",
|
|
417
|
+
});
|
|
418
|
+
case "locked":
|
|
419
|
+
return authorizationError({
|
|
420
|
+
...back,
|
|
421
|
+
error: "access_denied",
|
|
422
|
+
description: "Too many wrong codes.",
|
|
423
|
+
});
|
|
424
|
+
case "too_soon":
|
|
425
|
+
case "request_limit":
|
|
426
|
+
case "address_limit":
|
|
427
|
+
case "global_limit": {
|
|
428
|
+
// The page the user is on, with one neutral line: nothing says whether the address is
|
|
429
|
+
// known, and neither the address nor a code is logged.
|
|
430
|
+
const error = started.error === "too_soon"
|
|
431
|
+
? "Wait a minute before asking for another code."
|
|
432
|
+
: "Too many codes were sent. Try again later.";
|
|
433
|
+
return stored.status === "emailSent"
|
|
434
|
+
? emailCodePage({
|
|
435
|
+
appName: app.name,
|
|
436
|
+
action,
|
|
437
|
+
requestId,
|
|
438
|
+
email: stored.email,
|
|
439
|
+
error,
|
|
440
|
+
status: 429,
|
|
441
|
+
})
|
|
442
|
+
: emailAddressPage({ appName: app.name, action, requestId, error, status: 429 });
|
|
443
|
+
}
|
|
444
|
+
default:
|
|
445
|
+
return started;
|
|
446
|
+
}
|
|
377
447
|
}
|
|
378
448
|
try {
|
|
379
449
|
await ctx.runAction(emailConnector.send, { to: address, code, appName: app.name });
|
|
380
450
|
}
|
|
381
451
|
catch (error) {
|
|
382
|
-
|
|
452
|
+
// Only the class name: a delivery error can carry the address or the code.
|
|
453
|
+
console.error(`Connector "${emailConnector.name}" could not send a code: ${error instanceof Error ? error.constructor.name : typeof error}`);
|
|
383
454
|
return errorPage("Email not sent", "The code could not be sent. Try again in a moment.", 502);
|
|
384
455
|
}
|
|
385
456
|
return emailCodePage({ appName: app.name, action, requestId, email: address });
|
|
@@ -511,9 +582,12 @@ export function defineIdentityBroker(config) {
|
|
|
511
582
|
return oauthError(415, "invalid_request", "The body must be application/x-www-form-urlencoded.");
|
|
512
583
|
}
|
|
513
584
|
const params = new URLSearchParams(await request.text());
|
|
514
|
-
const
|
|
585
|
+
const authorization = request.headers.get("authorization");
|
|
586
|
+
// RFC 6749 section 5.2: a client that authenticated with the header gets a 401 with a challenge.
|
|
587
|
+
const challenge = authorization === null ? {} : { "WWW-Authenticate": 'Basic realm="token"' };
|
|
588
|
+
const clientAuth = readClientAuthentication(authorization, params);
|
|
515
589
|
if (!clientAuth.ok) {
|
|
516
|
-
return oauthError(clientAuth.status, clientAuth.error, clientAuth.description);
|
|
590
|
+
return oauthError(clientAuth.status, clientAuth.error, clientAuth.description, clientAuth.status === 401 ? challenge : {});
|
|
517
591
|
}
|
|
518
592
|
const validation = validateTokenRequest(params, clientAuth.value);
|
|
519
593
|
if (!validation.ok) {
|
|
@@ -525,7 +599,7 @@ export function defineIdentityBroker(config) {
|
|
|
525
599
|
if (client === null ||
|
|
526
600
|
client.tokenEndpointAuthMethod !== clientAuth.value.method ||
|
|
527
601
|
client.secretHash !== (await sha256Hex(clientAuth.value.clientSecret))) {
|
|
528
|
-
return oauthError(401, "invalid_client", "Client authentication failed.");
|
|
602
|
+
return oauthError(401, "invalid_client", "Client authentication failed.", challenge);
|
|
529
603
|
}
|
|
530
604
|
const now = Date.now();
|
|
531
605
|
const exchange = await ctx.runMutation(component.authRequests.exchangeCode, {
|
|
@@ -547,15 +621,17 @@ export function defineIdentityBroker(config) {
|
|
|
547
621
|
upstreamId: exchange.identity.upstreamId,
|
|
548
622
|
salt: keys.pairwiseSalt,
|
|
549
623
|
});
|
|
624
|
+
// Claims follow the granted scopes (OIDC Core 5.4); userinfo reads them from the access token.
|
|
625
|
+
const scopes = exchange.scope.split(" ");
|
|
550
626
|
const claims = { sub, connector: exchange.identity.connector };
|
|
551
|
-
if (exchange.identity.email !== undefined) {
|
|
627
|
+
if (scopes.includes("email") && exchange.identity.email !== undefined) {
|
|
552
628
|
claims.email = exchange.identity.email;
|
|
553
629
|
claims.email_verified = exchange.identity.emailVerified === true;
|
|
554
630
|
}
|
|
555
|
-
if (exchange.identity.name !== undefined) {
|
|
631
|
+
if (scopes.includes("profile") && exchange.identity.name !== undefined) {
|
|
556
632
|
claims.name = exchange.identity.name;
|
|
557
633
|
}
|
|
558
|
-
if (exchange.identity.picture !== undefined) {
|
|
634
|
+
if (scopes.includes("profile") && exchange.identity.picture !== undefined) {
|
|
559
635
|
claims.picture = exchange.identity.picture;
|
|
560
636
|
}
|
|
561
637
|
const [idToken, accessToken] = await Promise.all([
|
|
@@ -690,6 +766,28 @@ export function defineIdentityBroker(config) {
|
|
|
690
766
|
return null;
|
|
691
767
|
},
|
|
692
768
|
}),
|
|
769
|
+
listRegistrationTokens: internalQueryGeneric({
|
|
770
|
+
args: { appId: string() },
|
|
771
|
+
handler: async (ctx, args) => await ctx.runQuery(component.registration.listTokens, { appId: args.appId }),
|
|
772
|
+
}),
|
|
773
|
+
revokeRegistrationToken: internalMutationGeneric({
|
|
774
|
+
args: { tokenHash: string() },
|
|
775
|
+
returns: v.null(),
|
|
776
|
+
handler: async (ctx, args) => {
|
|
777
|
+
await ctx.runMutation(component.registration.revokeToken, {
|
|
778
|
+
tokenHash: args.tokenHash,
|
|
779
|
+
});
|
|
780
|
+
return null;
|
|
781
|
+
},
|
|
782
|
+
}),
|
|
783
|
+
deleteClient: internalMutationGeneric({
|
|
784
|
+
args: { clientId: string() },
|
|
785
|
+
returns: v.null(),
|
|
786
|
+
handler: async (ctx, args) => {
|
|
787
|
+
await ctx.runMutation(component.clients.remove, { clientId: args.clientId });
|
|
788
|
+
return null;
|
|
789
|
+
},
|
|
790
|
+
}),
|
|
693
791
|
deleteApp: internalMutationGeneric({
|
|
694
792
|
args: { appId: string() },
|
|
695
793
|
returns: v.null(),
|