@boxxy/auth-oauth 0.0.1 → 0.2.2

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.
Files changed (142) hide show
  1. package/README.md +47 -2
  2. package/dist/auth/answer-authorize-request.d.ts +2 -0
  3. package/dist/auth/answer-consent-request.d.ts +10 -0
  4. package/dist/auth/answer-enroll-request.d.ts +9 -0
  5. package/dist/auth/answer-login-request.d.ts +9 -0
  6. package/dist/auth/approval-window-seconds.d.ts +1 -0
  7. package/dist/auth/build-consent-binding.d.ts +1 -0
  8. package/dist/auth/create-approval-state.d.ts +41 -0
  9. package/dist/auth/create-auth-admin.d.ts +33 -0
  10. package/dist/auth/create-enrollment-state.d.ts +6 -0
  11. package/dist/auth/create-enrollment-token.d.ts +6 -0
  12. package/dist/auth/create-owner-plugin.d.ts +38 -0
  13. package/dist/auth/create-passkey.d.ts +11 -0
  14. package/dist/auth/create-security-event-log.d.ts +12 -0
  15. package/dist/auth/derive-token-hash.d.ts +1 -0
  16. package/dist/auth/format-security-event.d.ts +7 -0
  17. package/dist/auth/is-allowed-redirect-uri.d.ts +1 -0
  18. package/dist/auth/load-web-authn.d.ts +6 -0
  19. package/dist/auth/mcp-auth-database.d.ts +5 -0
  20. package/dist/auth/mcp-auth-schema.d.ts +54 -0
  21. package/dist/auth/normalize-client-name.d.ts +1 -0
  22. package/dist/auth/open-auth-database.d.ts +2 -0
  23. package/dist/auth/open-mcp-auth.d.ts +80 -0
  24. package/dist/auth/passkey-script.d.ts +4 -0
  25. package/dist/auth/read-client-name.d.ts +3 -0
  26. package/dist/auth/read-client-views.d.ts +9 -0
  27. package/dist/auth/read-enrollment-token.d.ts +3 -0
  28. package/dist/auth/read-grant-views.d.ts +12 -0
  29. package/dist/auth/read-owner-session-id.d.ts +2 -0
  30. package/dist/auth/record-refused-exchange.d.ts +10 -0
  31. package/dist/auth/remove-client.d.ts +3 -0
  32. package/dist/auth/remove-owner-session.d.ts +3 -0
  33. package/dist/auth/render-consent-page.d.ts +9 -0
  34. package/dist/auth/render-enroll-page.d.ts +10 -0
  35. package/dist/auth/render-login-page.d.ts +9 -0
  36. package/dist/auth/revoke-grant.d.ts +3 -0
  37. package/dist/auth/to-html-text.d.ts +1 -0
  38. package/dist/auth/to-script-json.d.ts +1 -0
  39. package/dist/auth/types.d.ts +79 -0
  40. package/dist/auth/update-passkey-use.d.ts +10 -0
  41. package/dist/auth/verify-oauth-query.d.ts +1 -0
  42. package/dist/auth/verify-passkey-assertion.d.ts +11 -0
  43. package/dist/auth/verify-passkey-registration.d.ts +14 -0
  44. package/dist/auth/verify-session-passkey.d.ts +8 -0
  45. package/dist/auth/write-grant-use.d.ts +3 -0
  46. package/dist/http/answer-mcp-request.d.ts +9 -0
  47. package/dist/http/build-page-response.d.ts +5 -0
  48. package/dist/http/create-mcp-http-app.d.ts +92 -0
  49. package/dist/http/create-public-door.d.ts +20 -0
  50. package/dist/http/is-loopback-host.d.ts +1 -0
  51. package/dist/http/normalize-public-url.d.ts +1 -0
  52. package/dist/http/pick-error-message.d.ts +1 -0
  53. package/dist/http/public-door-paths.d.ts +17 -0
  54. package/dist/http/read-token-access.d.ts +8 -0
  55. package/dist/http/verify-access-token.d.ts +2 -0
  56. package/dist/index.d.ts +8 -0
  57. package/dist/test-utils/authorize-test-client.d.ts +13 -0
  58. package/dist/test-utils/build-mock-new-passkey.d.ts +2 -0
  59. package/dist/test-utils/build-passkey-seeder.d.ts +5 -0
  60. package/dist/test-utils/build-seeder.d.ts +5 -0
  61. package/dist/test-utils/build-stub-authenticator.d.ts +48 -0
  62. package/dist/test-utils/build-stub-clock.d.ts +7 -0
  63. package/dist/test-utils/load-web-authn-early.d.ts +1 -0
  64. package/dist/test-utils/register-test-cleanup.d.ts +1 -0
  65. package/dist/test-utils/seed-passkey.d.ts +4 -0
  66. package/dist/test-utils/setup-temp-dir.d.ts +6 -0
  67. package/dist/test-utils/start-stub-telemetry-collector.d.ts +5 -0
  68. package/dist/test-utils/types.d.ts +7 -0
  69. package/dist/utils/is-record.d.ts +1 -0
  70. package/dist/utils/parse-json-strings.d.ts +1 -0
  71. package/dist/utils/types.d.ts +4 -0
  72. package/package.json +42 -1
  73. package/src/auth/answer-authorize-request.ts +137 -0
  74. package/src/auth/answer-consent-request.ts +189 -0
  75. package/src/auth/answer-enroll-request.ts +190 -0
  76. package/src/auth/answer-login-request.ts +256 -0
  77. package/src/auth/approval-window-seconds.ts +5 -0
  78. package/src/auth/build-consent-binding.ts +13 -0
  79. package/src/auth/create-approval-state.ts +221 -0
  80. package/src/auth/create-auth-admin.ts +78 -0
  81. package/src/auth/create-enrollment-state.ts +56 -0
  82. package/src/auth/create-enrollment-token.ts +28 -0
  83. package/src/auth/create-owner-plugin.ts +113 -0
  84. package/src/auth/create-passkey.ts +48 -0
  85. package/src/auth/create-security-event-log.ts +97 -0
  86. package/src/auth/derive-token-hash.ts +7 -0
  87. package/src/auth/format-security-event.ts +72 -0
  88. package/src/auth/is-allowed-redirect-uri.ts +19 -0
  89. package/src/auth/load-web-authn.ts +19 -0
  90. package/src/auth/mcp-auth-database.ts +10 -0
  91. package/src/auth/mcp-auth-schema.ts +56 -0
  92. package/src/auth/normalize-client-name.ts +22 -0
  93. package/src/auth/open-auth-database.ts +22 -0
  94. package/src/auth/open-mcp-auth.ts +138 -0
  95. package/src/auth/passkey-script.ts +50 -0
  96. package/src/auth/read-client-name.ts +17 -0
  97. package/src/auth/read-client-views.ts +27 -0
  98. package/src/auth/read-enrollment-token.ts +20 -0
  99. package/src/auth/read-grant-views.ts +53 -0
  100. package/src/auth/read-owner-session-id.ts +16 -0
  101. package/src/auth/record-refused-exchange.ts +74 -0
  102. package/src/auth/remove-client.ts +33 -0
  103. package/src/auth/remove-owner-session.ts +11 -0
  104. package/src/auth/render-consent-page.ts +61 -0
  105. package/src/auth/render-enroll-page.ts +59 -0
  106. package/src/auth/render-login-page.ts +58 -0
  107. package/src/auth/revoke-grant.ts +25 -0
  108. package/src/auth/to-html-text.ts +10 -0
  109. package/src/auth/to-script-json.ts +8 -0
  110. package/src/auth/types.ts +115 -0
  111. package/src/auth/update-passkey-use.ts +31 -0
  112. package/src/auth/verify-oauth-query.ts +41 -0
  113. package/src/auth/verify-passkey-assertion.ts +124 -0
  114. package/src/auth/verify-passkey-registration.ts +118 -0
  115. package/src/auth/verify-session-passkey.ts +47 -0
  116. package/src/auth/write-grant-use.ts +18 -0
  117. package/src/http/answer-mcp-request.ts +77 -0
  118. package/src/http/build-page-response.ts +26 -0
  119. package/src/http/create-mcp-http-app.ts +340 -0
  120. package/src/http/create-public-door.ts +150 -0
  121. package/src/http/is-loopback-host.ts +10 -0
  122. package/src/http/normalize-public-url.ts +29 -0
  123. package/src/http/pick-error-message.ts +21 -0
  124. package/src/http/public-door-paths.ts +21 -0
  125. package/src/http/read-token-access.ts +44 -0
  126. package/src/http/verify-access-token.ts +46 -0
  127. package/src/index.ts +8 -0
  128. package/src/test-utils/authorize-test-client.ts +194 -0
  129. package/src/test-utils/build-mock-new-passkey.ts +15 -0
  130. package/src/test-utils/build-passkey-seeder.ts +11 -0
  131. package/src/test-utils/build-seeder.ts +10 -0
  132. package/src/test-utils/build-stub-authenticator.ts +244 -0
  133. package/src/test-utils/build-stub-clock.ts +65 -0
  134. package/src/test-utils/load-web-authn-early.ts +8 -0
  135. package/src/test-utils/register-test-cleanup.ts +55 -0
  136. package/src/test-utils/seed-passkey.ts +32 -0
  137. package/src/test-utils/setup-temp-dir.ts +24 -0
  138. package/src/test-utils/start-stub-telemetry-collector.ts +27 -0
  139. package/src/test-utils/types.ts +11 -0
  140. package/src/utils/is-record.ts +5 -0
  141. package/src/utils/parse-json-strings.ts +15 -0
  142. package/src/utils/types.ts +8 -0
@@ -0,0 +1,11 @@
1
+ // Copied from atc: packages/mcp/src/auth/remove-owner-session.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
+
6
+ export async function removeOwnerSession(
7
+ db: Kysely<MCPAuthSchema>,
8
+ sessionID: string,
9
+ ): Promise<void> {
10
+ await db.deleteFrom('session').where('id', '=', sessionID).execute();
11
+ }
@@ -0,0 +1,61 @@
1
+ // Copied from atc: packages/mcp/src/auth/render-consent-page.ts at commit 4fe647cf.
2
+
3
+ import type { GrantScope } from '@boxxy/contract';
4
+ import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
5
+ import { toHTMLText } from './to-html-text.ts';
6
+
7
+ interface ConsentPageView {
8
+ // The signed authorization query the page posts back.
9
+ readonly oauthQuery: string;
10
+ readonly clientName: string;
11
+ readonly redirectURI: string;
12
+ readonly scopes: readonly GrantScope[];
13
+ }
14
+
15
+ const SCOPE_LABELS: Readonly<Record<GrantScope, string>> = {
16
+ read: 'Read items, their cost and times, and the runner status',
17
+ write:
18
+ 'File and change items, add notes, retry, wake and close them, and pause or resume the runner',
19
+ };
20
+
21
+ // Only reading starts granted; every scope that lets the client act starts
22
+ // withheld.
23
+ const GRANTED_BY_DEFAULT: ReadonlySet<GrantScope> = new Set<GrantScope>(['read']);
24
+
25
+ export function renderConsentPage(view: ConsentPageView): string {
26
+ const scopeRows = view.scopes
27
+ .map((scope) => {
28
+ const checked = GRANTED_BY_DEFAULT.has(scope) ? ' checked' : '';
29
+
30
+ return `<label><input type="checkbox" name="scope" value="${scope}"${checked}> ${toHTMLText(SCOPE_LABELS[scope])}</label>`;
31
+ })
32
+ .join('\n');
33
+
34
+ return `<!doctype html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>Choose what to allow</title>
40
+ <style>
41
+ body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
42
+ label { display: block; margin: 0.25rem 0; }
43
+ code { word-break: break-all; }
44
+ button { font: inherit; margin-right: 0.5rem; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <h1>Choose what to allow</h1>
49
+ <p><strong>${toHTMLText(view.clientName)}</strong> returns to <code>${toHTMLText(view.redirectURI)}</code> with the access you allow here.</p>
50
+ <form method="post" action="${PUBLIC_DOOR_PATHS.browser.consent}">
51
+ <input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
52
+ <fieldset>
53
+ <legend>Allow it to</legend>
54
+ ${scopeRows}
55
+ </fieldset>
56
+ <p><button type="submit" name="decision" value="approve">Allow</button><button type="submit" name="decision" value="deny">Deny</button></p>
57
+ </form>
58
+ </body>
59
+ </html>
60
+ `;
61
+ }
@@ -0,0 +1,59 @@
1
+ // Copied from atc: packages/mcp/src/auth/render-enroll-page.ts at commit 4fe647cf.
2
+
3
+ import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
4
+ import { PASSKEY_SCRIPT } from './passkey-script.ts';
5
+ import { toHTMLText } from './to-html-text.ts';
6
+ import { toScriptJSON } from './to-script-json.ts';
7
+
8
+ interface EnrollPageView {
9
+ // The enrollment token the owner typed, once it checked out, with the
10
+ // WebAuthn creation options for its challenge; null asks for the token.
11
+ readonly ceremony: { readonly token: string; readonly options: unknown } | null;
12
+ readonly error: string | null;
13
+
14
+ // The token a link carried, to fill in the form; absent when the owner types it
15
+ readonly prefill?: string;
16
+ }
17
+
18
+ export function renderEnrollPage(view: EnrollPageView): string {
19
+ const error = view.error === null ? '' : `<p class="error">${toHTMLText(view.error)}</p>`;
20
+
21
+ const step =
22
+ view.ceremony === null
23
+ ? `<p>Type the enrollment token that <code>boxxy auth enroll</code> printed, or open the link it printed.</p>
24
+ <form method="post" action="${PUBLIC_DOOR_PATHS.browser.enroll}">
25
+ <label>Enrollment token <input name="token" autocomplete="off" autofocus required${view.prefill === undefined ? '' : ` value="${toHTMLText(view.prefill)}"`}></label>
26
+ <button type="submit">Continue</button>
27
+ </form>`
28
+ : `<p>Create a passkey for boxxy. Your device asks you to verify yourself.</p>
29
+ <script type="application/json" id="boxxy-passkey">${toScriptJSON({ mode: 'create', options: view.ceremony.options })}</script>
30
+ <form method="post" action="${PUBLIC_DOOR_PATHS.browser.enroll}" id="boxxy-passkey-form">
31
+ <input type="hidden" name="token" value="${toHTMLText(view.ceremony.token)}">
32
+ <input type="hidden" name="credential" value="">
33
+ <button type="button" id="boxxy-passkey-start" autofocus>Create a passkey</button>
34
+ </form>
35
+ <p id="boxxy-passkey-status" class="error" role="status"></p>
36
+ <script>${PASSKEY_SCRIPT.source}</script>`;
37
+
38
+ return `<!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>Enroll a passkey for boxxy</title>
44
+ <style>
45
+ body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
46
+ input[name="token"] { font: inherit; font-family: ui-monospace, monospace; padding: 0.25rem; width: 100%; }
47
+ .error { color: #b00020; }
48
+ code { word-break: break-all; }
49
+ button { font: inherit; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <h1>Enroll a passkey for boxxy</h1>
54
+ ${error}
55
+ ${step}
56
+ </body>
57
+ </html>
58
+ `;
59
+ }
@@ -0,0 +1,58 @@
1
+ // Copied from atc: packages/mcp/src/auth/render-login-page.ts at commit 4fe647cf.
2
+
3
+ import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
4
+ import { PASSKEY_SCRIPT } from './passkey-script.ts';
5
+ import { toHTMLText } from './to-html-text.ts';
6
+ import { toScriptJSON } from './to-script-json.ts';
7
+
8
+ interface LoginPageView {
9
+ // The signed authorization query the page posts back.
10
+ readonly oauthQuery: string;
11
+ readonly clientName: string;
12
+ readonly redirectURI: string;
13
+
14
+ // The WebAuthn request options for this page's challenge, or null when
15
+ // no passkey is enrolled, so there is nothing to sign in with.
16
+ readonly options: unknown;
17
+ readonly error: string | null;
18
+ }
19
+
20
+ export function renderLoginPage(view: LoginPageView): string {
21
+ const error = view.error === null ? '' : `<p class="error">${toHTMLText(view.error)}</p>`;
22
+
23
+ const signIn =
24
+ view.options === null
25
+ ? `<p class="error">No passkey is enrolled, so nobody can sign in. On the host that runs boxxy, run <code>boxxy auth enroll</code>, then follow what it prints.</p>`
26
+ : `<script type="application/json" id="boxxy-passkey">${toScriptJSON({ mode: 'get', options: view.options })}</script>
27
+ <form method="post" action="${PUBLIC_DOOR_PATHS.browser.login}" id="boxxy-passkey-form">
28
+ <input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
29
+ <input type="hidden" name="credential" value="">
30
+ <button type="button" id="boxxy-passkey-start" autofocus>Sign in with a passkey</button>
31
+ </form>
32
+ <p id="boxxy-passkey-status" class="error" role="status"></p>
33
+ <script>${PASSKEY_SCRIPT.source}</script>`;
34
+
35
+ return `<!doctype html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
40
+ <title>Sign in to boxxy</title>
41
+ <style>
42
+ body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
43
+ .error { color: #b00020; }
44
+ code { word-break: break-all; }
45
+ button { font: inherit; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <h1>Sign in to boxxy</h1>
50
+ <p><strong>${toHTMLText(view.clientName)}</strong> wants access to your boxxy work.</p>
51
+ <p>After approval it returns to <code>${toHTMLText(view.redirectURI)}</code>.</p>
52
+ <p>Sign in with your passkey. You choose what to allow on the next page.</p>
53
+ ${error}
54
+ ${signIn}
55
+ </body>
56
+ </html>
57
+ `;
58
+ }
@@ -0,0 +1,25 @@
1
+ // Copied from atc: packages/mcp/src/auth/revoke-grant.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
+
6
+ export function revokeGrant(db: Kysely<MCPAuthSchema>, grantID: string): Promise<boolean> {
7
+ return db.transaction().execute(async (trx) => {
8
+ const grant = await trx
9
+ .selectFrom('oauthRefreshToken')
10
+ .select('clientId')
11
+ .where('authorizationCodeId', '=', grantID)
12
+ .executeTakeFirst();
13
+
14
+ if (grant === undefined) {
15
+ return false;
16
+ }
17
+
18
+ await trx.deleteFrom('oauthAccessToken').where('authorizationCodeId', '=', grantID).execute();
19
+ await trx.deleteFrom('oauthRefreshToken').where('authorizationCodeId', '=', grantID).execute();
20
+ await trx.deleteFrom('oauthConsent').where('clientId', '=', grant.clientId).execute();
21
+ await trx.deleteFrom('boxxy_grant_use').where('grant_id', '=', grantID).execute();
22
+
23
+ return true;
24
+ });
25
+ }
@@ -0,0 +1,10 @@
1
+ // Copied from atc: packages/mcp/src/auth/to-html-text.ts at commit 4fe647cf.
2
+
3
+ export function toHTMLText(text: string): string {
4
+ return text
5
+ .replaceAll('&', '&amp;')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ .replaceAll("'", '&#39;');
10
+ }
@@ -0,0 +1,8 @@
1
+ // Copied from atc: packages/mcp/src/auth/to-script-json.ts at commit 4fe647cf.
2
+
3
+ export function toScriptJSON(value: unknown): string {
4
+ return JSON.stringify(value)
5
+ .replaceAll('<', String.raw`\u003c`)
6
+ .replaceAll('>', String.raw`\u003e`)
7
+ .replaceAll('&', String.raw`\u0026`);
8
+ }
@@ -0,0 +1,115 @@
1
+ // Copied from atc: packages/mcp/src/auth/types.ts at commit 4fe647cf.
2
+
3
+ import type { GrantScope } from '@boxxy/contract';
4
+ import type { AuthClock } from '../utils/types.ts';
5
+ import type { ApprovalState } from './create-approval-state.ts';
6
+ import type { EnrollmentState } from './create-enrollment-state.ts';
7
+ import type { openMCPAuth } from './open-mcp-auth.ts';
8
+
9
+ export interface Requester {
10
+ readonly socketAddress: string | null;
11
+
12
+ // the `CF-Connecting-IP` header. Anyone can send it, so it is `trusted`
13
+ // only when the socket's peer is a proxy the owner listed.
14
+ readonly connectingIP: {
15
+ readonly address: string;
16
+ readonly provenance: 'trusted' | 'unverified';
17
+ } | null;
18
+ readonly userAgent: string | null;
19
+ }
20
+
21
+ export type SecurityOutcome =
22
+ | { readonly kind: 'authorize_started'; readonly outcome: 'accepted' }
23
+ | {
24
+ readonly kind: 'authorize_started';
25
+ readonly outcome: 'rejected';
26
+ readonly reason: 'rate_limited';
27
+ }
28
+ | {
29
+ readonly kind: 'authorize_invalid';
30
+ readonly outcome: 'rejected';
31
+ readonly reason: AuthorizeInvalidReason;
32
+ }
33
+ | { readonly kind: 'owner_auth'; readonly outcome: 'succeeded' }
34
+ | {
35
+ readonly kind: 'owner_auth';
36
+ readonly outcome: 'failed';
37
+ readonly reason?: OwnerAuthFailure;
38
+ }
39
+ | { readonly kind: 'consent'; readonly outcome: 'allowed' }
40
+ | { readonly kind: 'consent'; readonly outcome: 'denied'; readonly reason?: 'none_ticked' }
41
+ | {
42
+ readonly kind: 'token_exchange';
43
+ readonly outcome: 'rejected';
44
+ readonly reason: TokenExchangeReason;
45
+ }
46
+ | { readonly kind: 'bearer'; readonly outcome: 'rejected'; readonly reason: BearerReason };
47
+
48
+ export type OwnerAuthFailure =
49
+ | 'assertion_failed'
50
+ | 'locked'
51
+ | 'expired'
52
+ | 'not_enrolled'
53
+ | 'invalid_token';
54
+
55
+ export type AuthorizeInvalidReason =
56
+ | 'invalid_client'
57
+ | 'client_disabled'
58
+ | 'invalid_redirect'
59
+ | 'unauthorized_client'
60
+ | 'unsupported_response_type'
61
+ | 'invalid_request'
62
+ | 'invalid_scope'
63
+ | 'invalid_target'
64
+ | 'bad_host'
65
+ | 'bad_origin'
66
+ | 'other';
67
+
68
+ export type TokenExchangeReason =
69
+ | 'invalid_request'
70
+ | 'invalid_client'
71
+ | 'invalid_grant'
72
+ | 'unauthorized_client'
73
+ | 'unsupported_grant_type'
74
+ | 'invalid_scope'
75
+ | 'invalid_target'
76
+ | 'other';
77
+
78
+ type BearerReason = 'malformed' | 'invalid_token';
79
+
80
+ export type SecurityEvent = SecurityOutcome & {
81
+ readonly path: string;
82
+ readonly requester: Requester;
83
+ readonly client?: { readonly id: string; readonly name: string };
84
+ readonly redirectHost?: string;
85
+ readonly correlationID?: string;
86
+ };
87
+
88
+ // What a valid bearer token grants: the client it was issued to, and the
89
+ // scopes it holds.
90
+ export interface VerifiedAccess {
91
+ readonly clientID: string;
92
+ readonly clientName: string;
93
+ readonly scopes: readonly GrantScope[];
94
+ }
95
+
96
+ type MCPAuth = Awaited<ReturnType<typeof openMCPAuth>>;
97
+
98
+ // What every HTTP route reads: the authorization server, the approvals waiting for the owner, and
99
+ // the passkey enrollments in progress.
100
+ export interface HTTPServerContext {
101
+ // The public origin: the OAuth issuer.
102
+ readonly origin: string;
103
+
104
+ // `<origin>/mcp`: the one resource every token is bound to.
105
+ readonly resource: string;
106
+ readonly store: MCPAuth;
107
+ readonly approvals: ApprovalState;
108
+ readonly enrollments: EnrollmentState;
109
+
110
+ // Receives each sign-in outcome and each refused token as a security event.
111
+ readonly security: { readonly record: (event: SecurityEvent) => void };
112
+
113
+ // The time the routes stamp and check expiries against.
114
+ readonly clock: AuthClock;
115
+ }
@@ -0,0 +1,31 @@
1
+ // Copied from atc: packages/mcp/src/auth/update-passkey-use.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
+
6
+ interface PasskeyUse {
7
+ readonly id: string;
8
+
9
+ // The counter the verification read, which the passkey must still hold,
10
+ // so a passkey removed during the verification, or a concurrent assertion
11
+ // that stored a newer counter, makes this use fail.
12
+ readonly counter: number;
13
+ readonly newCounter: number;
14
+
15
+ // Epoch milliseconds, stamped as the passkey's last use.
16
+ readonly now: number;
17
+ }
18
+
19
+ export async function updatePasskeyUse(
20
+ db: Kysely<MCPAuthSchema>,
21
+ use: PasskeyUse,
22
+ ): Promise<boolean> {
23
+ const stored = await db
24
+ .updateTable('boxxy_passkey')
25
+ .set({ counter: use.newCounter, last_used_at: new Date(use.now).toISOString() })
26
+ .where('id', '=', use.id)
27
+ .where('counter', '=', use.counter)
28
+ .executeTakeFirst();
29
+
30
+ return stored.numUpdatedRows > 0n;
31
+ }
@@ -0,0 +1,41 @@
1
+ // Copied from atc: packages/mcp/src/auth/verify-oauth-query.ts at commit 4fe647cf.
2
+
3
+ import { constantTimeEqual, makeSignature } from 'better-auth/crypto';
4
+
5
+ export async function verifyOAuthQuery(
6
+ oauthQuery: string,
7
+ secret: string,
8
+ now: number,
9
+ ): Promise<boolean> {
10
+ const params = new URLSearchParams(oauthQuery);
11
+
12
+ const signatures = params.getAll('sig');
13
+ const [signature] = signatures;
14
+ const expiresAt = Number(params.get('exp')) * 1000;
15
+
16
+ if (signatures.length !== 1 || signature === undefined || signature === '') {
17
+ return false;
18
+ }
19
+
20
+ params.delete('sig');
21
+
22
+ // Ordered by name, then by value, comparing code units as better-auth does
23
+ // when it signs; the NUL separator sorts a name before any longer name it
24
+ // prefixes.
25
+ const canonical = new URLSearchParams(
26
+ [...params].toSorted(([keyA, valueA], [keyB, valueB]) => {
27
+ const pairA = `${keyA}\u0000${valueA}`;
28
+ const pairB = `${keyB}\u0000${valueB}`;
29
+
30
+ if (pairA === pairB) {
31
+ return 0;
32
+ }
33
+
34
+ return pairA < pairB ? -1 : 1;
35
+ }),
36
+ );
37
+
38
+ const expected = await makeSignature(canonical.toString(), secret);
39
+
40
+ return constantTimeEqual(signature, expected) && Number.isFinite(expiresAt) && expiresAt >= now;
41
+ }
@@ -0,0 +1,124 @@
1
+ // Copied from atc: packages/mcp/src/auth/verify-passkey-assertion.ts at commit 4fe647cf.
2
+
3
+ import type {
4
+ AuthenticationResponseJSON,
5
+ VerifiedAuthenticationResponse,
6
+ } from '@simplewebauthn/server';
7
+ import type { Kysely } from 'kysely';
8
+ import { z } from 'zod';
9
+ import { parseJSONStrings } from '../utils/parse-json-strings.ts';
10
+ import { loadWebAuthn } from './load-web-authn.ts';
11
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
12
+ import { updatePasskeyUse } from './update-passkey-use.ts';
13
+
14
+ interface AssertionCheck {
15
+ // The credential JSON the sign-in page posted.
16
+ readonly credential: string;
17
+
18
+ // The challenge the page was given, which the caller has already taken.
19
+ readonly challenge: string;
20
+ readonly origin: string;
21
+ readonly rpID: string;
22
+
23
+ // Epoch milliseconds, stamped as the passkey's last use.
24
+ readonly now: number;
25
+ }
26
+
27
+ export async function verifyPasskeyAssertion(
28
+ db: Kysely<MCPAuthSchema>,
29
+ check: AssertionCheck,
30
+ ): Promise<string | null> {
31
+ const response = parseAssertion(check.credential);
32
+
33
+ if (response === null) {
34
+ return null;
35
+ }
36
+
37
+ const passkey = await db
38
+ .selectFrom('boxxy_passkey')
39
+ .select(['id', 'public_key', 'counter', 'transports'])
40
+ .where('id', '=', response.id)
41
+ .executeTakeFirst();
42
+
43
+ if (passkey === undefined) {
44
+ return null;
45
+ }
46
+
47
+ const webAuthn = await loadWebAuthn();
48
+
49
+ let verified: VerifiedAuthenticationResponse;
50
+
51
+ try {
52
+ verified = await webAuthn.verifyAuthenticationResponse({
53
+ response,
54
+ expectedChallenge: check.challenge,
55
+ expectedOrigin: check.origin,
56
+ expectedRPID: check.rpID,
57
+ expectedType: 'webauthn.get',
58
+ requireUserVerification: true,
59
+ credential: {
60
+ id: passkey.id,
61
+ publicKey: new Uint8Array(Buffer.from(passkey.public_key, 'base64url')),
62
+ counter: passkey.counter,
63
+ transports: parseJSONStrings(passkey.transports) ?? [],
64
+ },
65
+ });
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ if (!verified.verified || !verified.authenticationInfo.userVerified) {
71
+ return null;
72
+ }
73
+
74
+ const stored = await updatePasskeyUse(db, {
75
+ id: passkey.id,
76
+ counter: passkey.counter,
77
+ newCounter: verified.authenticationInfo.newCounter,
78
+ now: check.now,
79
+ });
80
+
81
+ return stored ? passkey.id : null;
82
+ }
83
+
84
+ const ASSERTION_REPLY = z.object({
85
+ clientDataJSON: z.string(),
86
+ authenticatorData: z.string(),
87
+ signature: z.string(),
88
+ userHandle: z.string().optional(),
89
+ });
90
+
91
+ const ASSERTION = z.object({
92
+ id: z.string().min(1),
93
+ rawId: z.string().min(1),
94
+ type: z.literal('public-key'),
95
+ response: ASSERTION_REPLY,
96
+ });
97
+
98
+ // The posted JSON when it has the shape of an authentication response,
99
+ // else null; the verifier checks every value.
100
+ function parseAssertion(credential: string): AuthenticationResponseJSON | null {
101
+ let parsed: unknown;
102
+
103
+ try {
104
+ parsed = JSON.parse(credential);
105
+ } catch {
106
+ return null;
107
+ }
108
+
109
+ const result = ASSERTION.safeParse(parsed);
110
+
111
+ if (!result.success) {
112
+ return null;
113
+ }
114
+
115
+ const { userHandle, ...response } = result.data.response;
116
+
117
+ return {
118
+ id: result.data.id,
119
+ rawId: result.data.rawId,
120
+ type: result.data.type,
121
+ response: userHandle === undefined ? response : { ...response, userHandle },
122
+ clientExtensionResults: {},
123
+ };
124
+ }
@@ -0,0 +1,118 @@
1
+ // Copied from atc: packages/mcp/src/auth/verify-passkey-registration.ts at commit 4fe647cf.
2
+
3
+ import type { RegistrationResponseJSON } from '@simplewebauthn/server';
4
+ import { z } from 'zod';
5
+ import { loadWebAuthn } from './load-web-authn.ts';
6
+
7
+ interface RegistrationCheck {
8
+ // The credential JSON the enrollment page posted.
9
+ readonly credential: string;
10
+
11
+ // The challenge the page was given, which the caller has already taken.
12
+ readonly challenge: string;
13
+ readonly origin: string;
14
+ readonly rpID: string;
15
+ }
16
+
17
+ export interface RegisteredPasskey {
18
+ readonly id: string;
19
+ readonly publicKey: Uint8Array;
20
+ readonly counter: number;
21
+ readonly transports: readonly string[];
22
+ }
23
+
24
+ export async function verifyPasskeyRegistration(
25
+ check: RegistrationCheck,
26
+ ): Promise<RegisteredPasskey | null> {
27
+ const response = parseRegistration(check.credential);
28
+
29
+ if (response === null) {
30
+ return null;
31
+ }
32
+
33
+ const webAuthn = await loadWebAuthn();
34
+
35
+ try {
36
+ const verified = await webAuthn.verifyRegistrationResponse({
37
+ response,
38
+ expectedChallenge: check.challenge,
39
+ expectedOrigin: check.origin,
40
+ expectedRPID: check.rpID,
41
+ expectedType: 'webauthn.create',
42
+ requireUserVerification: true,
43
+ });
44
+
45
+ if (!verified.verified || !verified.registrationInfo.userVerified) {
46
+ return null;
47
+ }
48
+
49
+ const credential = verified.registrationInfo.credential;
50
+
51
+ return {
52
+ id: credential.id,
53
+ publicKey: credential.publicKey,
54
+ counter: credential.counter,
55
+ transports: response.response.transports ?? [],
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ const TRANSPORTS: ReadonlySet<string> = new Set([
63
+ 'ble',
64
+ 'cable',
65
+ 'hybrid',
66
+ 'internal',
67
+ 'nfc',
68
+ 'smart-card',
69
+ 'usb',
70
+ ]);
71
+
72
+ const REGISTRATION_REPLY = z.object({
73
+ clientDataJSON: z.string(),
74
+ attestationObject: z.string(),
75
+ transports: z.string().array().optional(),
76
+ });
77
+
78
+ const REGISTRATION = z.object({
79
+ id: z.string().min(1),
80
+ rawId: z.string().min(1),
81
+ type: z.literal('public-key'),
82
+ response: REGISTRATION_REPLY,
83
+ });
84
+
85
+ // The posted JSON when it has the shape of a registration response, keeping
86
+ // only the transports WebAuthn defines, else null; the verifier checks
87
+ // every value.
88
+ function parseRegistration(credential: string): RegistrationResponseJSON | null {
89
+ let parsed: unknown;
90
+
91
+ try {
92
+ parsed = JSON.parse(credential);
93
+ } catch {
94
+ return null;
95
+ }
96
+
97
+ const result = REGISTRATION.safeParse(parsed);
98
+
99
+ if (!result.success) {
100
+ return null;
101
+ }
102
+
103
+ const transports = (result.data.response.transports ?? []).filter((transport) =>
104
+ TRANSPORTS.has(transport),
105
+ );
106
+
107
+ return {
108
+ id: result.data.id,
109
+ rawId: result.data.rawId,
110
+ type: result.data.type,
111
+ response: {
112
+ clientDataJSON: result.data.response.clientDataJSON,
113
+ attestationObject: result.data.response.attestationObject,
114
+ transports,
115
+ },
116
+ clientExtensionResults: {},
117
+ };
118
+ }