@tempoxyz/id 0.0.1 → 0.0.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 (120) hide show
  1. package/dist/app/server/.vite/manifest.json +65 -65
  2. package/dist/app/server/assets/Grant-D_tun0mF.js.map +1 -1
  3. package/dist/app/server/assets/authorize-DXYo-K2n-B-6vqhvc.js +56 -0
  4. package/dist/app/server/assets/authorize-DXYo-K2n-B-6vqhvc.js.map +1 -0
  5. package/dist/app/server/assets/core-FSS9-r35.js +56 -0
  6. package/dist/app/server/assets/core-FSS9-r35.js.map +1 -0
  7. package/dist/app/server/assets/default-u_dwuiYb-Bby14ucv.js +2 -0
  8. package/dist/app/server/assets/{default-u_dwuiYb-DOrMMcg9.js.map → default-u_dwuiYb-Bby14ucv.js.map} +1 -1
  9. package/dist/app/server/assets/{dist-CoH0hD1s.js → dist-B9KiBJYw.js} +2 -2
  10. package/dist/app/server/assets/{dist-CoH0hD1s.js.map → dist-B9KiBJYw.js.map} +1 -1
  11. package/dist/app/server/assets/{dist-DcICI3fd.js → dist-CU1NXSiz.js} +2 -2
  12. package/dist/app/server/assets/{dist-DcICI3fd.js.map → dist-CU1NXSiz.js.map} +1 -1
  13. package/dist/app/server/assets/dist-CetH4JSY.js +1 -0
  14. package/dist/app/server/assets/{effect-QlVUlMFu-mXPkuQTb.js → effect-QlVUlMFu-Cm-eTtaO.js} +2 -2
  15. package/dist/app/server/assets/{effect-QlVUlMFu-mXPkuQTb.js.map → effect-QlVUlMFu-Cm-eTtaO.js.map} +1 -1
  16. package/dist/app/server/assets/factory-g3i_Ffi5.js +5 -0
  17. package/dist/app/server/assets/{factory-dvoOlKer.js.map → factory-g3i_Ffi5.js.map} +1 -1
  18. package/dist/app/server/assets/get-id-field-BpOAoqqI.js +6 -0
  19. package/dist/app/server/assets/get-id-field-BpOAoqqI.js.map +1 -0
  20. package/dist/app/server/assets/{index-CLddUTqr-CC33CMvS.js → index-CLddUTqr-CmgkbWi9.js} +2 -2
  21. package/dist/app/server/assets/{index-CLddUTqr-CC33CMvS.js.map → index-CLddUTqr-CmgkbWi9.js.map} +1 -1
  22. package/dist/app/server/assets/{index-DZEfthgZ-Ec2MMQ_e.js → index-DZEfthgZ-BnPmps9O.js} +2 -2
  23. package/dist/app/server/assets/{index-DZEfthgZ-Ec2MMQ_e.js.map → index-DZEfthgZ-BnPmps9O.js.map} +1 -1
  24. package/dist/app/server/assets/kysely-adapter-B8VoPArC.js +1 -0
  25. package/dist/app/server/assets/{schema-check-oLqvHR8y.js → schema-check-Bgwl1v0_.js} +2 -2
  26. package/dist/app/server/assets/{schema-check-oLqvHR8y.js.map → schema-check-Bgwl1v0_.js.map} +1 -1
  27. package/dist/app/server/assets/{sury-CWZTCd75-CDaOS2oM.js → sury-CWZTCd75-Bd_PzqCV.js} +2 -2
  28. package/dist/app/server/assets/{sury-CWZTCd75-CDaOS2oM.js.map → sury-CWZTCd75-Bd_PzqCV.js.map} +1 -1
  29. package/dist/app/server/assets/{valibot--1zFm7rT-CIKoH3MS.js → valibot--1zFm7rT-Dd2aEhWG.js} +2 -2
  30. package/dist/app/server/assets/{valibot--1zFm7rT-CIKoH3MS.js.map → valibot--1zFm7rT-Dd2aEhWG.js.map} +1 -1
  31. package/dist/app/server/assets/{valibot-D_HTw1Gn-BdPyLkXt.js → valibot-D_HTw1Gn-C1l3va6F.js} +2 -2
  32. package/dist/app/server/assets/{valibot-D_HTw1Gn-BdPyLkXt.js.map → valibot-D_HTw1Gn-C1l3va6F.js.map} +1 -1
  33. package/dist/app/server/assets/{zod-Bwrt9trS-BezI5Gi1.js → zod-Bwrt9trS-Dry2w0in.js} +2 -2
  34. package/dist/app/server/assets/{zod-Bwrt9trS-BezI5Gi1.js.map → zod-Bwrt9trS-Dry2w0in.js.map} +1 -1
  35. package/dist/app/server/assets/{zod-DSgpEGAE-DZ7M_Q33.js → zod-DSgpEGAE-BZMP86UA.js} +2 -2
  36. package/dist/app/server/assets/{zod-DSgpEGAE-DZ7M_Q33.js.map → zod-DSgpEGAE-BZMP86UA.js.map} +1 -1
  37. package/dist/app/server/index.js +35 -35
  38. package/dist/app/server/index.js.map +1 -1
  39. package/dist/client/Auth.d.ts +1 -1
  40. package/dist/client/Auth.d.ts.map +1 -1
  41. package/dist/client/Auth.js.map +1 -1
  42. package/dist/client/Decorator.d.ts +3 -1
  43. package/dist/client/Decorator.d.ts.map +1 -1
  44. package/dist/client/Decorator.js +1 -0
  45. package/dist/client/Decorator.js.map +1 -1
  46. package/dist/client/actions/passkey.d.ts +63 -2
  47. package/dist/client/actions/passkey.d.ts.map +1 -1
  48. package/dist/client/actions/passkey.js +121 -3
  49. package/dist/client/actions/passkey.js.map +1 -1
  50. package/dist/internal/Apps.d.ts +14 -0
  51. package/dist/internal/Apps.d.ts.map +1 -1
  52. package/dist/internal/Apps.js +14 -2
  53. package/dist/internal/Apps.js.map +1 -1
  54. package/dist/internal/Auth.d.ts +7 -0
  55. package/dist/internal/Auth.d.ts.map +1 -1
  56. package/dist/internal/Auth.js +2 -1
  57. package/dist/internal/Auth.js.map +1 -1
  58. package/dist/internal/Authorize.d.ts.map +1 -1
  59. package/dist/internal/Authorize.js +13 -13
  60. package/dist/internal/Authorize.js.map +1 -1
  61. package/dist/internal/ErrorResponse.d.ts +5 -0
  62. package/dist/internal/ErrorResponse.d.ts.map +1 -1
  63. package/dist/internal/ErrorResponse.js +8 -0
  64. package/dist/internal/ErrorResponse.js.map +1 -1
  65. package/dist/internal/KeyAuthorization.d.ts.map +1 -1
  66. package/dist/internal/KeyAuthorization.js +28 -28
  67. package/dist/internal/KeyAuthorization.js.map +1 -1
  68. package/dist/internal/Owner.d.ts +16 -0
  69. package/dist/internal/Owner.d.ts.map +1 -1
  70. package/dist/internal/Owner.js.map +1 -1
  71. package/dist/internal/Passkey.d.ts +66 -4
  72. package/dist/internal/Passkey.d.ts.map +1 -1
  73. package/dist/internal/Passkey.js +66 -30
  74. package/dist/internal/Passkey.js.map +1 -1
  75. package/dist/internal/Privy.d.ts +27 -0
  76. package/dist/internal/Privy.d.ts.map +1 -1
  77. package/dist/internal/Privy.js +82 -0
  78. package/dist/internal/Privy.js.map +1 -1
  79. package/dist/internal/TempoAccount.d.ts +57 -0
  80. package/dist/internal/TempoAccount.d.ts.map +1 -1
  81. package/dist/internal/TempoAccount.js +118 -5
  82. package/dist/internal/TempoAccount.js.map +1 -1
  83. package/dist/internal/owners/Privy.d.ts.map +1 -1
  84. package/dist/internal/owners/Privy.js +11 -2
  85. package/dist/internal/owners/Privy.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/api/account.ts +140 -103
  88. package/src/api/authenticate.ts +48 -51
  89. package/src/api/authorize.ts +35 -36
  90. package/src/api/device.ts +20 -23
  91. package/src/api/health.ts +1 -2
  92. package/src/api/lib/AccountStep.ts +75 -0
  93. package/src/api/lib/Auth.ts +6 -0
  94. package/src/api/lib/Cors.ts +3 -0
  95. package/src/api/lib/KeySigner.ts +1 -1
  96. package/src/api/oauth.ts +23 -22
  97. package/src/client/Auth.ts +1 -0
  98. package/src/client/Decorator.ts +6 -1
  99. package/src/client/actions/passkey.ts +197 -3
  100. package/src/internal/Apps.ts +26 -2
  101. package/src/internal/Auth.ts +4 -0
  102. package/src/internal/Authorize.ts +13 -15
  103. package/src/internal/ErrorResponse.ts +10 -0
  104. package/src/internal/KeyAuthorization.ts +28 -31
  105. package/src/internal/OpenApi.ts +35 -0
  106. package/src/internal/Owner.ts +16 -0
  107. package/src/internal/Passkey.ts +99 -34
  108. package/src/internal/Privy.ts +128 -0
  109. package/src/internal/TempoAccount.ts +221 -7
  110. package/src/internal/owners/Privy.ts +19 -6
  111. package/dist/app/server/assets/authorize-DXYo-K2n-rmQQ42x1.js +0 -56
  112. package/dist/app/server/assets/authorize-DXYo-K2n-rmQQ42x1.js.map +0 -1
  113. package/dist/app/server/assets/core-BBrZXfrB.js +0 -56
  114. package/dist/app/server/assets/core-BBrZXfrB.js.map +0 -1
  115. package/dist/app/server/assets/default-u_dwuiYb-DOrMMcg9.js +0 -2
  116. package/dist/app/server/assets/dist-CR6FW-yS.js +0 -1
  117. package/dist/app/server/assets/factory-dvoOlKer.js +0 -5
  118. package/dist/app/server/assets/get-id-field-OwLpfjj2.js +0 -6
  119. package/dist/app/server/assets/get-id-field-OwLpfjj2.js.map +0 -1
  120. package/dist/app/server/assets/kysely-adapter-B8tQwTcu.js +0 -1
package/src/api/oauth.ts CHANGED
@@ -75,7 +75,7 @@ namespace schema {
75
75
  .optional()
76
76
  .meta({
77
77
  description:
78
- "A message, such as a sign-in challenge from the app's backend, whose EIP-191 hash becomes the key authorization's `witness` (TIP-1053). Requires `key_authorization`, and the app's `tempo-id:sign` scope.",
78
+ "A message, such as a sign-in challenge, that the key authorization's `witness` binds (TIP-1053). Requires `key_authorization` and the `tempo-id:sign` scope.",
79
79
  examples: ['Sign in to app.example.com\nNonce: 7f3a9c2e5b1d4f60'],
80
80
  }),
81
81
  client_id: z.string().meta({
@@ -95,7 +95,7 @@ namespace schema {
95
95
  .optional()
96
96
  .meta({
97
97
  description:
98
- "A key authorization for the app's own key, as base64url JSON in the shape of `KeyAuthorizationRequest`. The approval page shows the key, and `/oauth2/token` returns it beside the tokens once approval signs it. A key that breaks a rule returns to the redirect URI with the refusal.",
98
+ "A `KeyAuthorizationRequest` for the app's own key, as base64url JSON. After approval, `/oauth2/token` returns the signed key with the tokens.",
99
99
  examples: [
100
100
  'eyJhZGRyZXNzIjoiMHg4MWMwZjIyYmQxNzNjMTE1ZDE4ZWVlYTBmNmEzOGU5MzVlY2Y1MmZiIn0',
101
101
  ],
@@ -145,7 +145,7 @@ namespace schema {
145
145
  }),
146
146
  url: z.string().meta({
147
147
  description:
148
- 'Where the browser goes next: the sign-in page, the consent page, or the redirect URI with a code.',
148
+ 'The URL to send the browser to next: the sign-in page, the approval page, or the redirect URI with a code.',
149
149
  examples: [
150
150
  'https://app.example/callback?code=SplxlOBeZQQYbYS6WxSbIA',
151
151
  ],
@@ -310,7 +310,7 @@ namespace schema {
310
310
  }),
311
311
  key_authorization: KeyAuthorization.schema.rpc.optional().meta({
312
312
  description:
313
- 'The key authorization that the approval page or the device page signed, for the first redemption of a code or device code whose request carried `key_authorization`. Absent for every other code, a refresh, and a key that expired.',
313
+ 'The signed key authorization, on the first redemption of a code whose request carried `key_authorization`. Absent otherwise.',
314
314
  }),
315
315
  refresh_token: z
316
316
  .string()
@@ -404,7 +404,7 @@ namespace schema {
404
404
  }),
405
405
  key_authorization: authorize.query.shape.key_authorization.meta({
406
406
  description:
407
- "A key authorization for the app's own key, as base64url JSON in the shape of `KeyAuthorizationRequest`, from a public first-party app only. The device page shows it, approval signs it, and `/oauth2/token` returns it beside the tokens.",
407
+ "A `KeyAuthorizationRequest` for the app's own key, as base64url JSON, from a public first-party app only. After approval, `/oauth2/token` returns the signed key with the tokens.",
408
408
  examples: [
409
409
  'eyJhZGRyZXNzIjoiMHg4MWMwZjIyYmQxNzNjMTE1ZDE4ZWVlYTBmNmEzOGU5MzVlY2Y1MmZiIn0',
410
410
  ],
@@ -585,7 +585,7 @@ namespace schema {
585
585
  .optional()
586
586
  .meta({
587
587
  description:
588
- 'The sealed parameters of a form POST that carried no session cookie, which its 303 sends here. Absent otherwise.',
588
+ 'The sealed parameters of a form POST that had no session cookie, from its 303 redirect.',
589
589
  examples: ['a3f1c2…'],
590
590
  }),
591
591
  id_token_hint: z
@@ -593,7 +593,7 @@ namespace schema {
593
593
  .optional()
594
594
  .meta({
595
595
  description:
596
- 'An ID token that Tempo ID issued to the app, even an expired one, which names the session to end. Required without `continuation`.',
596
+ 'An ID token that Tempo ID issued to the app, which names the session to end. It may be expired, and is required without `continuation`.',
597
597
  examples: ['eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.e30.c2lnbmF0dXJl'],
598
598
  }),
599
599
  post_logout_redirect_uri: z
@@ -617,8 +617,7 @@ namespace schema {
617
617
 
618
618
  export const response = z.object({
619
619
  signed_out: z.literal(true).meta({
620
- description:
621
- 'The browser holds no session that the hint names, since it ended or never held one.',
620
+ description: 'The browser has no session that the hint names.',
622
621
  }),
623
622
  })
624
623
  }
@@ -703,7 +702,7 @@ const introspectRoute = {
703
702
  /** The description and responses of `GET` and `POST /oauth2/end-session` in the OpenAPI document. */
704
703
  const endSessionRoute = {
705
704
  description:
706
- "Signs the browser out of the session that the app's ID token names, if the browser holds it, and revokes the tokens that apps received through it (OpenID Connect RP-Initiated Logout). Any other session stays signed in, and Tempo ID redirects the browser to the app's post-logout redirect URI.",
705
+ "Signs the browser out of the session that `id_token_hint` names, revokes its tokens, and redirects to the app's post-logout redirect URI (OpenID Connect RP-Initiated Logout).",
707
706
  responses: {
708
707
  302: {
709
708
  description:
@@ -713,7 +712,7 @@ const endSessionRoute = {
713
712
  errors: [...OpenApi.validate.errors, ...Logout.check.errors],
714
713
  success: {
715
714
  description:
716
- 'The browser is signed out of the session that the hint names, for a request without `post_logout_redirect_uri`.',
715
+ 'The browser is signed out, for a request without `post_logout_redirect_uri`.',
717
716
  schema: schema.endSession.response,
718
717
  },
719
718
  }),
@@ -824,7 +823,7 @@ export const oauth = new Hono<Env>()
824
823
  .get(
825
824
  '/.well-known/openid-configuration',
826
825
  OpenApi.describeRoute({
827
- description: "Reads the issuer's OpenID Connect discovery document.",
826
+ description: "Returns the issuer's OpenID Connect discovery document.",
828
827
  responses: OpenApi.responses({
829
828
  success: {
830
829
  description: 'The discovery document.',
@@ -838,7 +837,8 @@ export const oauth = new Hono<Env>()
838
837
  .get(
839
838
  '/.well-known/oauth-authorization-server',
840
839
  OpenApi.describeRoute({
841
- description: "Reads the issuer's authorization server metadata.",
840
+ description:
841
+ "Returns the issuer's authorization server metadata (RFC 8414).",
842
842
  responses: OpenApi.responses({
843
843
  success: {
844
844
  description: 'The metadata.',
@@ -853,7 +853,7 @@ export const oauth = new Hono<Env>()
853
853
  '/oauth2/authorize',
854
854
  OpenApi.describeRoute({
855
855
  description:
856
- 'Starts an authorization. A signed-out browser receives the sign-in page, and a signed-in one the consent page or a redirect to the redirect URI with a code.',
856
+ 'Redirect authorization, step 1 of 2: starts the authorization in the browser. Tempo ID shows the sign-in, account, and approval pages as needed, then redirects to `redirect_uri` with a code.',
857
857
  responses: {
858
858
  200: {
859
859
  content: {
@@ -863,7 +863,7 @@ export const oauth = new Hono<Env>()
863
863
  'text/html': { schema: { type: 'string' } },
864
864
  },
865
865
  description:
866
- "The next step as JSON, for a request from a script. A browser navigation receives Tempo ID's page of the next step, such as the sign-in page or the error page.",
866
+ 'The next step as JSON, for a request from a script. A browser navigation receives the page of the next step.',
867
867
  },
868
868
  302: {
869
869
  description:
@@ -904,7 +904,7 @@ export const oauth = new Hono<Env>()
904
904
  '/oauth2/token',
905
905
  OpenApi.describeRoute({
906
906
  description:
907
- 'Redeems an authorization code, a refresh token, a device code, or client credentials for tokens.',
907
+ 'Exchanges an authorization code, refresh token, device code, or client credentials for tokens. It is the last step of the redirect authorization and of the device grant.',
908
908
  responses: {
909
909
  200: {
910
910
  content: {
@@ -995,7 +995,7 @@ export const oauth = new Hono<Env>()
995
995
  '/device/code',
996
996
  OpenApi.describeRoute({
997
997
  description:
998
- 'Starts the device grant (RFC 8628 §3.1). The user approves the user code on the device page, and the device then redeems the device code at `/oauth2/token`.',
998
+ 'Device grant, step 1 of 4: returns a device code and a user code (RFC 8628). The user enters the user code on the device page, and the device polls `/oauth2/token`.',
999
999
  responses: {
1000
1000
  200: {
1001
1001
  content: {
@@ -1125,7 +1125,7 @@ export const oauth = new Hono<Env>()
1125
1125
  '/oauth2/introspect',
1126
1126
  OpenApi.describeRoute({
1127
1127
  description:
1128
- 'Introspects a token (RFC 7662). The app authenticates with its client credentials, in the body or as HTTP Basic `Authorization`.',
1128
+ 'Returns whether a token is active, and its claims (RFC 7662). The app authenticates with its client credentials.',
1129
1129
  ...introspectRoute,
1130
1130
  }),
1131
1131
  OpenApi.input('form', schema.introspect.form),
@@ -1135,7 +1135,7 @@ export const oauth = new Hono<Env>()
1135
1135
  '/oauth2/revoke',
1136
1136
  OpenApi.describeRoute({
1137
1137
  description:
1138
- 'Revokes an access or refresh token (RFC 7009). The app authenticates with its client credentials, in the body or as HTTP Basic `Authorization`.',
1138
+ 'Revokes an access or refresh token (RFC 7009). The app authenticates with its client credentials.',
1139
1139
  responses: {
1140
1140
  200: {
1141
1141
  content: {
@@ -1160,7 +1160,8 @@ export const oauth = new Hono<Env>()
1160
1160
  .get(
1161
1161
  '/oauth2/userinfo',
1162
1162
  OpenApi.describeRoute({
1163
- description: "Reads the token subject's claims (OIDC Core §5.3).",
1163
+ description:
1164
+ "Returns the claims of the access token's user (OIDC Core §5.3).",
1164
1165
  responses: {
1165
1166
  200: {
1166
1167
  content: {
@@ -1192,7 +1193,7 @@ export const oauth = new Hono<Env>()
1192
1193
  ...OpenApi.errors(BodyLimit.errors),
1193
1194
  303: {
1194
1195
  description:
1195
- 'Continues a form POST without the session cookie as `GET /oauth2/end-session` with the sealed parameters in `continuation`, which the browser sends with the cookie.',
1196
+ 'Redirects a form POST without the session cookie to `GET /oauth2/end-session`, which the browser sends with the cookie.',
1196
1197
  },
1197
1198
  },
1198
1199
  }),
@@ -1203,7 +1204,7 @@ export const oauth = new Hono<Env>()
1203
1204
  '/oauth2/jwks',
1204
1205
  OpenApi.describeRoute({
1205
1206
  description:
1206
- "Reads the issuer's signing keys. A verifier may cache the response for 60 seconds, since a new key signs 5 minutes after its rotation.",
1207
+ "Returns the issuer's signing keys. A verifier may cache the response for 60 seconds.",
1207
1208
  responses: OpenApi.responses({
1208
1209
  success: {
1209
1210
  description: 'The JSON Web Key Set.',
@@ -158,6 +158,7 @@ export declare namespace RequestError {
158
158
  | 'passkey_limit'
159
159
  | 'passkey_link_expired'
160
160
  | 'passkey_not_permitted'
161
+ | 'passkey_register_not_permitted'
161
162
  | 'post_logout_redirect_not_permitted'
162
163
  | 'rate_limited'
163
164
  | 'reauthentication_required'
@@ -228,6 +228,7 @@ export function tempoId(options: tempoId.Options) {
228
228
  passkeySignIn(() => prepared.signIn(signInOptions)),
229
229
  }
230
230
  },
231
+ register: (options = {}) => Actions.passkey.register(auth, options),
231
232
  signIn: (options = {}) =>
232
233
  passkeySignIn(() => Actions.passkey.signIn(auth, options)),
233
234
  },
@@ -360,7 +361,7 @@ export declare namespace tempoId {
360
361
  /** `Actions.oauth.userInfo`. */
361
362
  userInfo: (options: Actions.oauth.userInfo.Options) => Promise<User>
362
363
  }
363
- /** Actions for sign-in with a passkey. */
364
+ /** Actions for sign-in with a passkey, and the registration of one. */
364
365
  passkey: {
365
366
  /** `Actions.passkey.available`. */
366
367
  available: () => Promise<boolean>
@@ -374,6 +375,10 @@ export declare namespace tempoId {
374
375
  >(
375
376
  options?: Actions.passkey.prepare.Options<id, key>,
376
377
  ) => Actions.passkey.prepare.ReturnType<id, key>
378
+ /** `Actions.passkey.register`, which leaves the client's account unchanged. */
379
+ register: (
380
+ options?: Actions.passkey.register.Options,
381
+ ) => Promise<Actions.passkey.register.ReturnType>
377
382
  /**
378
383
  * `Actions.passkey.signIn`, which sets the client's account as `auth.email.signIn` does,
379
384
  * unless it returns a step.
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Signs the user in with a passkey from the app's own sign-in form, and authorizes the app's key.
3
3
  * The handle of {@link prepare} requests the sign-in's options before the user acts, so its sign-in
4
- * starts the ceremony without a request.
4
+ * starts the ceremony without a request. {@link register} adds a passkey for the signed-in user
5
+ * from the app's page.
5
6
  * @module
6
7
  */
7
- import { Authentication, type Types } from 'ox/webauthn'
8
+ import {
9
+ Authentication,
10
+ Credential,
11
+ Registration,
12
+ type Types,
13
+ } from 'ox/webauthn'
8
14
  import type { Account } from 'viem/tempo'
9
- import { type Auth, ChallengeWithoutKeyError } from '../Auth.js'
15
+ import { type Auth, ChallengeWithoutKeyError, RequestError } from '../Auth.js'
10
16
  import type { Authorization } from '../Authorization.js'
17
+ import type * as Key from '../Key.js'
11
18
  import type * as AccessKey from '../internal/accessKey.js'
12
19
  import * as Api from '../internal/api.js'
13
20
  import * as Headless from '../internal/headless.js'
@@ -160,6 +167,143 @@ export declare namespace prepare {
160
167
  type SignInOptions = Pick<signIn.Options, 'getFn'>
161
168
  }
162
169
 
170
+ /**
171
+ * Registers a new passkey for the signed-in user from a first-party app's page, and returns its
172
+ * credential ID and the address of the Tempo Account that it owns. The passkey belongs to the
173
+ * `default` realm's relying party, which must cover the page and Tempo ID. It runs two ceremonies:
174
+ * the authenticator creates the passkey, then the new passkey signs a proof challenge, so call it
175
+ * from a click.
176
+ *
177
+ * With `key`, the key of a sign-in on a cloud wallet's configurable account, the passkey joins that
178
+ * account as a second owner on the key's chain, through an update that the cloud wallet signs. The
179
+ * key keeps working. On a chain whose accounts are not configurable, `key` sets nothing, and the
180
+ * passkey owns an account of its own. `account` and `chain` name the account and chain instead.
181
+ *
182
+ * Requires a sign-in within the last 10 minutes, or throws `reauthentication_required`. A user who
183
+ * holds 10 passkeys throws `passkey_limit`, and a page that the relying party does not cover throws
184
+ * `passkey_not_permitted`.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * const { authorization } = await Actions.email.signIn(auth, { code, email })
189
+ * const { address, id } = await Actions.passkey.register(auth, {
190
+ * key: authorization.key,
191
+ * })
192
+ * ```
193
+ */
194
+ export async function register(
195
+ auth: Auth,
196
+ options: register.Options = {},
197
+ ): Promise<register.ReturnType> {
198
+ const { createFn, getFn, key } = options
199
+ // The key's chain resolves beside the options request, so it adds no round trip. Only a chain
200
+ // whose accounts are configurable takes an owner, so on any other the passkey owns its own account.
201
+ const joined =
202
+ key && (options.account === undefined || options.chain === undefined)
203
+ ? configurableChain(auth, { chainId: key.chainId }).then((name) =>
204
+ name ? { account: key.account, chain: name } : undefined,
205
+ )
206
+ : undefined
207
+ // A registration that fails before the verify request reads no chain, so its failure stays silent.
208
+ joined?.catch(() => {})
209
+ const requested = await Api.json<{
210
+ options: Types.CredentialCreationOptions<true>
211
+ }>(await Api.post(auth, '/v1/account/passkey/options', { json: {} }))
212
+ const deserialized = Registration.deserializeOptions(requested.options)
213
+ const credential = Credential.serialize(
214
+ await Registration.create(
215
+ // ox reads `createFn` beside creation options at runtime, which its types admit only beside flat options.
216
+ createFn ? ({ ...deserialized, createFn } as never) : deserialized,
217
+ ),
218
+ )
219
+
220
+ const created = {
221
+ credentialId: credential.id,
222
+ rpId: requested.options.publicKey!.rp.id!,
223
+ }
224
+
225
+ // The attestation proves no private key, so the new passkey signs a proof challenge before
226
+ // Tempo ID stores it. A failure before the proof leaves the passkey unstored, so the
227
+ // authenticator drops it.
228
+ const assertion = await (async () => {
229
+ try {
230
+ const { step } = await Api.json<{
231
+ step: { options: Types.CredentialRequestOptions<true> }
232
+ }>(await Api.post(auth, '/v1/account/passkey', { json: { credential } }))
233
+ const proof = Authentication.deserializeOptions(step.options)
234
+ return Authentication.serializeResponse(
235
+ await Authentication.sign(
236
+ // ox reads `getFn` beside request options at runtime, which its types admit only beside a challenge.
237
+ getFn ? ({ ...proof, getFn } as never) : proof,
238
+ ),
239
+ )
240
+ } catch (error) {
241
+ forget(created)
242
+ throw error
243
+ }
244
+ })()
245
+
246
+ // A failed chain lookup comes before the verify request, so Tempo ID stored nothing.
247
+ const target = await joined?.catch((error: unknown) => {
248
+ forget(created)
249
+ throw error
250
+ })
251
+ const account = options.account ?? target?.account
252
+ const chain = options.chain ?? target?.chain
253
+ try {
254
+ const registered = await Api.json<register.ReturnType>(
255
+ await Api.post(auth, '/v1/account/passkey/verify', {
256
+ json: {
257
+ ...(account ? { account } : {}),
258
+ assertion,
259
+ ...(chain ? { chain } : {}),
260
+ },
261
+ }),
262
+ )
263
+ return { address: registered.address, id: registered.id }
264
+ } catch (error) {
265
+ // Any failure but a refusal can follow a stored passkey, so the authenticator keeps it.
266
+ if (refused(error)) forget(created)
267
+ throw error
268
+ }
269
+ }
270
+
271
+ export declare namespace register {
272
+ type Options = {
273
+ /**
274
+ * A configurable Tempo Account of the user's cloud wallet, which the passkey joins as a second
275
+ * owner on `chain`. Defaults to `key.account` on a chain whose accounts are configurable, else
276
+ * none, and the passkey owns an account of its own.
277
+ */
278
+ account?: `0x${string}` | undefined
279
+ /**
280
+ * The chain whose accounts are configurable, by its name in `GET /v1/chains`, such as
281
+ * `testnet`. It selects where `account` is, and the chain of the address that the call returns.
282
+ * Defaults to the chain of `key` when its accounts are configurable, else the first such chain
283
+ * for `account`, and the passkey's own address otherwise.
284
+ */
285
+ chain?: string | undefined
286
+ /** Runs the creation ceremony in place of `navigator.credentials.create`. */
287
+ createFn?: Registration.create.Options['createFn'] | undefined
288
+ /** Runs the proof ceremony in place of `navigator.credentials.get`. */
289
+ getFn?: Authentication.sign.Options['getFn'] | undefined
290
+ /**
291
+ * The key of a sign-in on the cloud wallet's account, such as `authorization.key`. On a chain
292
+ * whose accounts are configurable, it sets the default `account` and `chain`. Registration does
293
+ * not use the key otherwise.
294
+ */
295
+ key?: Pick<Key.Key, 'account' | 'chainId'> | undefined
296
+ }
297
+
298
+ /** The new passkey and the account that it owns. */
299
+ type ReturnType = {
300
+ /** The address of the Tempo Account that the passkey owns: `account`, or its own on `chain`. */
301
+ address: `0x${string}`
302
+ /** The credential ID of the passkey, base64url. */
303
+ id: string
304
+ }
305
+ }
306
+
163
307
  /**
164
308
  * Signs the user in with a passkey, and authorizes a first-party app with the same assertion. The
165
309
  * key must fit the headless ceiling. A user without a Tempo Account receives one.
@@ -243,6 +387,56 @@ export declare namespace signIn {
243
387
  }
244
388
  }
245
389
 
390
+ /**
391
+ * Returns the name of the chain with `chainId` in `GET /v1/chains` whose accounts are configurable,
392
+ * or `undefined` when the deployment flags no such chain.
393
+ */
394
+ async function configurableChain(auth: Auth, options: { chainId: number }) {
395
+ const { chains } = await Api.json<{
396
+ chains: readonly { chainId: number; configurable: boolean; name: string }[]
397
+ }>(await (auth.fetch ?? fetch)(new URL('/v1/chains', auth.url)))
398
+ return chains.find(
399
+ (entry) => entry.chainId === options.chainId && entry.configurable,
400
+ )?.name
401
+ }
402
+
403
+ /**
404
+ * Signals the browser to drop the passkey `credentialId`, which Tempo ID did not store, where the
405
+ * browser supports the signal. The signal is best effort: a passkey that stays signs in nowhere.
406
+ */
407
+ function forget(options: { credentialId: string; rpId: string }) {
408
+ if (typeof PublicKeyCredential === 'undefined') return
409
+ const signal = (
410
+ PublicKeyCredential as {
411
+ signalUnknownCredential?: (options: {
412
+ credentialId: string
413
+ rpId: string
414
+ }) => Promise<void>
415
+ }
416
+ ).signalUnknownCredential
417
+ // The registration's result does not depend on the signal, so its failure is ignored.
418
+ void signal
419
+ ?.call(PublicKeyCredential, {
420
+ credentialId: options.credentialId,
421
+ rpId: options.rpId,
422
+ })
423
+ .catch(() => {})
424
+ }
425
+
426
+ /**
427
+ * Returns whether Tempo ID refused a request with a 4xx and a `reason`, so it stored nothing. An
428
+ * owner change can return `config_conflict` or `rate_limited` after it stores the passkey.
429
+ */
430
+ function refused(error: unknown) {
431
+ return (
432
+ error instanceof RequestError &&
433
+ error.reason !== undefined &&
434
+ error.reason !== 'config_conflict' &&
435
+ error.reason !== 'rate_limited' &&
436
+ error.status < 500
437
+ )
438
+ }
439
+
246
440
  /** A passkey sign-in's options and the key that they authorize, which one sign-in uses. */
247
441
  type Prepared = {
248
442
  /** The authorization request that the options carry, which the passkey step repeats. */
@@ -359,7 +359,7 @@ const seeded = new Set<string>()
359
359
 
360
360
  /** Returns the Better Auth plugins that serve OAuth apps: the JWT plugin and the OAuth provider. */
361
361
  export function plugins(options: plugins.Options) {
362
- const { accountStep, baseURL, cache, pendingKey } = options
362
+ const { accounts, accountStep, baseURL, cache, pendingKey } = options
363
363
  const fetch = options.metadataFetch ?? fetchMetadata()
364
364
  return [
365
365
  // `iss` is the configured origin, never the request's host. No client reads the session's JWT
@@ -395,9 +395,22 @@ export function plugins(options: plugins.Options) {
395
395
  // Every app may request the one resource.
396
396
  enforcePerClientResources: false,
397
397
  // A third-party app names its metadata document's URL or its origin as its client ID, and
398
- // registers no row.
398
+ // registers no row. A registered app's access token names the user's accounts in the app's
399
+ // realms, read again on each grant, so a refresh lists an account that the user created since.
400
+ // A third-party app receives none, as `GET /v1/account` lists them only to registered apps.
399
401
  extensions: [
400
402
  {
403
+ claims: {
404
+ accessToken: async (input) => ({
405
+ tempo_accounts:
406
+ accounts && input.user && !thirdParty(input.client.clientId)
407
+ ? await accounts({
408
+ clientId: input.client.clientId,
409
+ userId: input.user.id,
410
+ })
411
+ : [],
412
+ }),
413
+ },
401
414
  clientDiscovery: [discovery({ fetch }), originDiscovery({ fetch })],
402
415
  },
403
416
  ],
@@ -553,6 +566,17 @@ export function plugins(options: plugins.Options) {
553
566
 
554
567
  export declare namespace plugins {
555
568
  type Options = {
569
+ /**
570
+ * Returns every address of the user's Tempo Accounts in the app's realms, which each access
571
+ * token of a registered app names as `tempo_accounts`. A third-party app's tokens, and every
572
+ * token without this option, name an empty array.
573
+ */
574
+ accounts?:
575
+ | ((options: {
576
+ clientId: string
577
+ userId: string
578
+ }) => Promise<readonly string[]>)
579
+ | undefined
556
580
  /** Returns whether the app's authorization needs `/choose-account`. Absent, none does. */
557
581
  accountStep?:
558
582
  | ((options: {
@@ -109,6 +109,7 @@ export const sessionCookie = 'tempo-id-session'
109
109
  /** Returns the Better Auth options for `options`. */
110
110
  export function options(options: options.Options) {
111
111
  const {
112
+ accounts,
112
113
  accountStep,
113
114
  baseURL,
114
115
  cache,
@@ -191,6 +192,7 @@ export function options(options: options.Options) {
191
192
  }),
192
193
  Passkey.plugin(),
193
194
  ...Apps.plugins({
195
+ accounts,
194
196
  accountStep,
195
197
  baseURL,
196
198
  cache,
@@ -240,6 +242,8 @@ export function options(options: options.Options) {
240
242
 
241
243
  export declare namespace options {
242
244
  type Options = {
245
+ /** Lists the addresses of the user's accounts that each access token names. */
246
+ accounts?: Apps.plugins.Options['accounts']
243
247
  /** Decides whether an authorization needs the account step. */
244
248
  accountStep?: Apps.plugins.Options['accountStep']
245
249
  /** Tempo ID's origin, such as `https://id.tempo.xyz`. */
@@ -43,7 +43,7 @@ export namespace schema {
43
43
  .object({
44
44
  access_token: z.string().meta({
45
45
  description:
46
- "The access token, a JWT that Tempo ID signs. APIs verify it against Tempo ID's JSON Web Key Set.",
46
+ "The access token, a JWT that APIs verify against Tempo ID's JSON Web Key Set.",
47
47
  examples: ['eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2lnbmF0dXJl'],
48
48
  }),
49
49
  expires_in: z.number().meta({
@@ -55,7 +55,7 @@ export namespace schema {
55
55
  .optional()
56
56
  .meta({
57
57
  description:
58
- 'The OIDC ID token, a JWT about the user. Absent when the request did not ask for the `openid` scope.',
58
+ 'The OIDC ID token, a JWT about the user. Absent without the `openid` scope.',
59
59
  examples: ['eyJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJhcHAifQ.c2lnbmF0dXJl'],
60
60
  }),
61
61
  refresh_token: z
@@ -63,7 +63,7 @@ export namespace schema {
63
63
  .optional()
64
64
  .meta({
65
65
  description:
66
- 'The refresh token, which the app redeems at `/oauth2/token` for new tokens. Absent without the `offline_access` scope.',
66
+ 'The refresh token, for new tokens at `/oauth2/token`. Absent without the `offline_access` scope.',
67
67
  examples: ['yAjhKk7QnRZ3vD9pLm2Xc8Ws5Tf1Ub4N'],
68
68
  }),
69
69
  token_type: z.string().meta({
@@ -81,7 +81,7 @@ export namespace schema {
81
81
  .optional()
82
82
  .meta({
83
83
  description:
84
- "A message, such as a sign-in challenge from the app's backend, whose EIP-191 hash becomes the key authorization's `witness` (TIP-1053). Requires `key_authorization`, and the app's `tempo-id:sign` scope.",
84
+ "A message, such as a sign-in challenge, that the key authorization's `witness` binds (TIP-1053). Requires `key_authorization` and the `tempo-id:sign` scope.",
85
85
  examples: ['Sign in to app.example.com\nNonce: 7f3a9c2e5b1d4f60'],
86
86
  })
87
87
 
@@ -103,7 +103,7 @@ export namespace schema {
103
103
  .optional()
104
104
  .meta({
105
105
  description:
106
- "The S256 PKCE challenge of a verifier that the app's backend holds (RFC 7636). With one, the app receives a code, and without one, a public app receives tokens.",
106
+ "An S256 PKCE challenge (RFC 7636). With it, the response holds a code for the app's backend, and without it, tokens.",
107
107
  examples: ['E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
108
108
  }),
109
109
  key_authorization: KeyAuthorization.schema.request,
@@ -115,7 +115,7 @@ export namespace schema {
115
115
  })
116
116
  .meta({
117
117
  description:
118
- 'A registered redirect URI of a web app, whose origin the page calls from.',
118
+ 'A registered redirect URI of the app, on the origin of the page.',
119
119
  examples: ['https://app.example.com/callback'],
120
120
  }),
121
121
  scope: z
@@ -132,7 +132,7 @@ export namespace schema {
132
132
  .optional()
133
133
  .meta({
134
134
  description:
135
- "Whether a wallet.tempo passkey's account that waits for its upgrade on the key's chain, where accounts are configurable, signs the key as the passkey's own account, in place of `upgrade_required`. It applies when a passkey signs in the same request, through a sign-in with key signature or `passkey`. The response then holds `upgrade_available`. The key stops working on that chain once the account upgrades, unless `POST /v1/account/upgrade` registers it first. Defaults to `false`.",
135
+ "Whether to sign the key before a pending upgrade of a wallet.tempo passkey's account, in place of `upgrade_required`. The key then stops working after the upgrade, unless the upgrade registers it.",
136
136
  examples: [true],
137
137
  }),
138
138
  state: z
@@ -141,8 +141,7 @@ export namespace schema {
141
141
  .max(256)
142
142
  .optional()
143
143
  .meta({
144
- description:
145
- 'An opaque value that the response returns unchanged, to bind it to the request (RFC 6749 §4.1.1).',
144
+ description: 'An opaque value that the response returns unchanged.',
146
145
  examples: ['af0ifjsldkj'],
147
146
  }),
148
147
  })
@@ -193,7 +192,7 @@ export namespace schema {
193
192
  .optional()
194
193
  .meta({
195
194
  description:
196
- "The authorization code, when the request sent a `code_challenge`. The app's backend redeems it at `/oauth2/token`.",
195
+ "The authorization code, if the request sent a `code_challenge`. The app's backend redeems it at `/oauth2/token`.",
197
196
  examples: ['Vb7mQ2xLkP9sTzR4'],
198
197
  }),
199
198
  key_authorization: KeyAuthorization.schema.rpc.optional(),
@@ -201,21 +200,20 @@ export namespace schema {
201
200
  .string()
202
201
  .optional()
203
202
  .meta({
204
- description:
205
- "The request's `state`, unchanged. Absent when the request sent none.",
203
+ description: "The request's `state`, unchanged.",
206
204
  examples: ['af0ifjsldkj'],
207
205
  }),
208
206
  tokens: tokens.optional().meta({
209
- description: 'The tokens, when the request sent no `code_challenge`.',
207
+ description: 'The tokens, if the request sent no `code_challenge`.',
210
208
  }),
211
209
  upgrade_available: z.literal(true).optional().meta({
212
210
  description:
213
- "Present when `skip_upgrade` signed the key as a wallet.tempo passkey's own account, whose upgrade waits on the key's chain. `POST /v1/account/upgrade` with the key registers it and upgrades the account.",
211
+ 'Present if `skip_upgrade` signed the key before a pending upgrade. Send the key to `POST /v1/account/upgrade` to keep it working after the upgrade.',
214
212
  }),
215
213
  })
216
214
  .meta({
217
215
  description:
218
- 'An authorization: a code or tokens, with the signed key authorization when the request asks for one.',
216
+ 'An authorization: a code or tokens, with any signed key authorization.',
219
217
  id: 'Authorization',
220
218
  })
221
219
  }
@@ -183,6 +183,12 @@ export const reasons = {
183
183
  error: 'unauthorized_client',
184
184
  status: 403,
185
185
  },
186
+ passkey_register_not_permitted: {
187
+ description:
188
+ "A passkey registration comes from an app's page, and no app at the page's origin holds the `tempo-id:sign` scope.",
189
+ error: 'unauthorized_client',
190
+ status: 403,
191
+ },
186
192
  not_configurable: {
187
193
  description:
188
194
  'The account is not configurable on the chain, so no owner can join it, or no passkey owns it, so it cannot upgrade.',
@@ -638,6 +644,10 @@ export namespace schema {
638
644
  error_description:
639
645
  'Passkey not permitted: the app client_01JZ8Q6W3N1R5B4V7C2X9K0M3T holds no passkeys. Sign in with an emailed code.',
640
646
  },
647
+ passkey_register_not_permitted: {
648
+ error_description:
649
+ 'Passkey registration not permitted: no app at https://app.example.com holds the `tempo-id:sign` scope. Register the passkey on Tempo ID, or grant the app the scope.',
650
+ },
641
651
  not_configurable: {
642
652
  error_description:
643
653
  'Not configurable: 0x8C3b5d0e2A4f6B1c9D7e3F5a0B2c4D6e8F1a3B5c is not a configurable account yet. Upgrade it with its passkey, then add the owner.',