@glydi/passkey-firebase 0.1.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 +84 -0
- package/dist/chunk-JP6DMV6X.js +21 -0
- package/dist/chunk-JP6DMV6X.js.map +1 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -0
- package/dist/express.d.ts +122 -0
- package/dist/express.js +163 -0
- package/dist/express.js.map +1 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @glydi/passkey-firebase
|
|
2
|
+
|
|
3
|
+
Drop **Glide passkeys** onto an app that already uses **Firebase Auth** — without
|
|
4
|
+
replacing anything. A passkey login becomes *another door into the same Firebase
|
|
5
|
+
session*: your existing `verifyIdToken` middleware, custom-claim roles, and user
|
|
6
|
+
records keep working untouched.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
passkey verified → mint Firebase custom token → signInWithCustomToken → normal Firebase session
|
|
10
|
+
(Glide) (this package, server) (this package, client) (your app, unchanged)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Three tree-shakeable entry points:
|
|
14
|
+
|
|
15
|
+
| Import | Side | Purpose |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `@glydi/passkey-firebase/server` | backend | `createFirebaseBridge({ auth })` → an `onAuthSuccess` that mints a custom token and returns it in the response |
|
|
18
|
+
| `@glydi/passkey-firebase/express` | backend | `toExpressHandler(handler)` — adapts Glide's Web-standard handler to Express `(req, res)` |
|
|
19
|
+
| `@glydi/passkey-firebase/client` | browser | `completePasskeySignIn(auth, result)` — exchanges the minted token via `signInWithCustomToken` |
|
|
20
|
+
|
|
21
|
+
Peers (install what you use): `firebase-admin` (server), `firebase` (client),
|
|
22
|
+
`express` (express adapter), `@glydi/passkey-server`.
|
|
23
|
+
|
|
24
|
+
## Server (Express)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { getAuth } from "firebase-admin/auth";
|
|
28
|
+
import { Router } from "express";
|
|
29
|
+
import { createGlideServer, createPasskeyRouteHandler } from "@glydi/passkey-server";
|
|
30
|
+
import { createFirebaseBridge } from "@glydi/passkey-firebase/server";
|
|
31
|
+
import { toExpressHandler } from "@glydi/passkey-firebase/express";
|
|
32
|
+
|
|
33
|
+
const glide = createGlideServer({ rpName, rpID, origin, store }); // store: your GlideStore
|
|
34
|
+
|
|
35
|
+
const glideHandler = toExpressHandler(
|
|
36
|
+
createPasskeyRouteHandler({
|
|
37
|
+
server: glide,
|
|
38
|
+
getSessionId, // read/set the glide_sid cookie
|
|
39
|
+
getUserId: async (req) => req.user?.uid, // verified Firebase UID (register-*)
|
|
40
|
+
onAuthSuccess: createFirebaseBridge({ auth: getAuth() }),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const router = Router();
|
|
45
|
+
// Login: public — the passkey IS the proof.
|
|
46
|
+
router.post("/authenticate-begin", glideHandler);
|
|
47
|
+
router.post("/authenticate-finish", glideHandler);
|
|
48
|
+
// Register (add-a-passkey): behind your existing auth — links to the signed-in UID.
|
|
49
|
+
router.post("/register-begin", requireAuth, glideHandler);
|
|
50
|
+
router.post("/register-finish", requireAuth, glideHandler);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Identity rule:** your `GlideStore` must set `user.id` to the **Firebase UID**.
|
|
54
|
+
> `createFirebaseBridge` mints the token for exactly that id.
|
|
55
|
+
|
|
56
|
+
## Client
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { getAuth } from "firebase/auth";
|
|
60
|
+
import { PasskeyButton } from "@glydi/passkey-react";
|
|
61
|
+
import { completePasskeySignIn } from "@glydi/passkey-firebase/client";
|
|
62
|
+
|
|
63
|
+
<PasskeyButton
|
|
64
|
+
mode="signin"
|
|
65
|
+
endpoints={{
|
|
66
|
+
authenticateBegin: `${API}/api/v1/passkey/authenticate-begin`,
|
|
67
|
+
authenticateFinish: `${API}/api/v1/passkey/authenticate-finish`,
|
|
68
|
+
registerBegin: `${API}/api/v1/passkey/register-begin`,
|
|
69
|
+
registerFinish: `${API}/api/v1/passkey/register-finish`,
|
|
70
|
+
}}
|
|
71
|
+
fetchOptions={{ credentials: "include" }} // carries the glide_sid cookie
|
|
72
|
+
onSuccess={(result) => completePasskeySignIn(getAuth(), result)}
|
|
73
|
+
/>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
After `completePasskeySignIn`, Firebase's `onIdTokenChanged` fires and your app
|
|
77
|
+
proceeds exactly as if the user signed in any other way.
|
|
78
|
+
|
|
79
|
+
## Cross-origin note
|
|
80
|
+
|
|
81
|
+
If your web app and API are on different domains, the `glide_sid` cookie must be
|
|
82
|
+
`SameSite=None; Secure`, the client must send `credentials: "include"` (set above),
|
|
83
|
+
and the API's CORS must allow credentials and echo the web origin. `rpID` is the
|
|
84
|
+
**browser** domain (where the button renders), independent of where the API runs.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
function createFirebaseBridge(options) {
|
|
3
|
+
const field = options.tokenField ?? "accessToken";
|
|
4
|
+
return async function onAuthSuccess(user) {
|
|
5
|
+
if (!user?.id) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"[glide-firebase] Cannot mint a custom token: the verified user has no id. Ensure your GlideStore sets user.id to the Firebase UID."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
const claims = options.developerClaims ? await options.developerClaims(user) : void 0;
|
|
11
|
+
const token = await options.auth.createCustomToken(
|
|
12
|
+
user.id,
|
|
13
|
+
claims
|
|
14
|
+
);
|
|
15
|
+
return { [field]: token };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { createFirebaseBridge };
|
|
20
|
+
//# sourceMappingURL=out.js.map
|
|
21
|
+
//# sourceMappingURL=chunk-JP6DMV6X.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":";AAqEO,SAAS,qBACd,SAC+E;AAC/E,QAAM,QAAQ,QAAQ,cAAc;AAEpC,SAAO,eAAe,cAAc,MAAM;AACxC,QAAI,CAAC,MAAM,IAAI;AACb,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAEA,UAAM,SAAS,QAAQ,kBACnB,MAAM,QAAQ,gBAAgB,IAAI,IAClC;AAEJ,UAAM,QAAQ,MAAM,QAAQ,KAAK;AAAA,MAC/B,KAAK;AAAA,MACL;AAAA,IACF;AAEA,WAAO,EAAE,CAAC,KAAK,GAAG,MAAM;AAAA,EAC1B;AACF","sourcesContent":["/**\n * Server bridge: Glide passkey → Firebase custom token.\n *\n * Glide's WebAuthn ceremony verifies the user and hands you `{ user }` (where\n * `user.id` is whatever id your store assigned — set it to the Firebase UID).\n * This bridge turns that verified user into a *Firebase custom token* by calling\n * `admin.auth().createCustomToken(uid)`, and returns it shaped so Glide's route\n * handler merges it into the JSON response (`{ accessToken }`). The browser then\n * exchanges it via `signInWithCustomToken` (see `@glydi/passkey-firebase/client`).\n *\n * The net effect: a passkey login becomes *another door into the same Firebase\n * session* — your existing `verifyIdToken` middleware, custom-claim roles, and\n * user records keep working untouched.\n *\n * @example\n * ```ts\n * import { getAuth } from \"firebase-admin/auth\";\n * import { createPasskeyRouteHandler } from \"@glydi/passkey-server\";\n * import { createFirebaseBridge } from \"@glydi/passkey-firebase/server\";\n *\n * const handler = createPasskeyRouteHandler({\n * server: glide,\n * getSessionId,\n * getUserId, // verified Firebase UID (register-*)\n * onAuthSuccess: createFirebaseBridge({ auth: getAuth() }),\n * });\n * ```\n */\n\nimport type { Auth } from \"firebase-admin/auth\";\n\n/** The authenticated-user shape Glide hands to `onAuthSuccess`. */\nexport interface GlideBridgeUser {\n /** Stable user id from your store — MUST be the Firebase UID for the bridge. */\n id: string;\n name?: string;\n email?: string;\n}\n\nexport interface FirebaseBridgeOptions {\n /**\n * A firebase-admin `Auth` instance, e.g. `getAuth()` from \"firebase-admin/auth\".\n * Injected (not imported) so this package never bundles firebase-admin and you\n * keep a single initialized Admin app.\n */\n auth: Auth;\n\n /**\n * Optional: extra Firebase custom claims to embed in *this* minted token.\n * Receives the Glide user (`user.id` === Firebase UID). Useful for one-shot\n * claims; for durable role claims prefer `auth.setCustomUserClaims(uid, ...)`\n * in your own onboarding flow.\n */\n developerClaims?: (\n user: GlideBridgeUser,\n ) => Record<string, unknown> | Promise<Record<string, unknown>>;\n\n /**\n * Response field that carries the minted token to the client.\n * Defaults to `\"accessToken\"` (already typed on Glide's `AuthResult`, so the\n * client helper and React `onSuccess` see it with no extra wiring).\n */\n tokenField?: string;\n}\n\n/**\n * Build an `onAuthSuccess` callback for `createPasskeyRouteHandler` that mints a\n * Firebase custom token for the verified user and returns it in the response body.\n */\nexport function createFirebaseBridge(\n options: FirebaseBridgeOptions,\n): (user: GlideBridgeUser, request?: unknown) => Promise<Record<string, string>> {\n const field = options.tokenField ?? \"accessToken\";\n\n return async function onAuthSuccess(user) {\n if (!user?.id) {\n throw new Error(\n \"[glide-firebase] Cannot mint a custom token: the verified user has no id. \" +\n \"Ensure your GlideStore sets user.id to the Firebase UID.\",\n );\n }\n\n const claims = options.developerClaims\n ? await options.developerClaims(user)\n : undefined;\n\n const token = await options.auth.createCustomToken(\n user.id,\n claims as object | undefined,\n );\n\n return { [field]: token };\n };\n}\n"]}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Auth, UserCredential } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Client helper: exchange a Glide passkey result for a Firebase session.
|
|
5
|
+
*
|
|
6
|
+
* After a passkey ceremony succeeds, Glide's `onSuccess(result)` carries the
|
|
7
|
+
* minted Firebase custom token on `result.accessToken` (because the server
|
|
8
|
+
* bridge returned it). Hand that to Firebase and you get a normal signed-in
|
|
9
|
+
* session — `onIdTokenChanged` fires and the rest of your app proceeds exactly
|
|
10
|
+
* as if the user had signed in any other way.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { getAuth } from "firebase/auth";
|
|
15
|
+
* import { completePasskeySignIn } from "@glydi/passkey-firebase/client";
|
|
16
|
+
*
|
|
17
|
+
* <PasskeyButton
|
|
18
|
+
* mode="signin"
|
|
19
|
+
* endpoints={{ ... }}
|
|
20
|
+
* fetchOptions={{ credentials: "include" }}
|
|
21
|
+
* onSuccess={(result) => completePasskeySignIn(getAuth(), result)}
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Minimal shape of Glide's `AuthResult` this helper needs. */
|
|
27
|
+
interface PasskeyResultLike {
|
|
28
|
+
/** Firebase custom token minted by the server bridge (`createFirebaseBridge`). */
|
|
29
|
+
accessToken?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Complete a Glide passkey sign-in by exchanging the minted custom token for a
|
|
33
|
+
* Firebase session via `signInWithCustomToken`.
|
|
34
|
+
*
|
|
35
|
+
* @throws if `result.accessToken` is missing — almost always means the server
|
|
36
|
+
* side isn't wired to `createFirebaseBridge` (so no token was returned).
|
|
37
|
+
*/
|
|
38
|
+
declare function completePasskeySignIn(auth: Auth, result: PasskeyResultLike): Promise<UserCredential>;
|
|
39
|
+
|
|
40
|
+
export { type PasskeyResultLike, completePasskeySignIn };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { signInWithCustomToken } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
// src/client.ts
|
|
4
|
+
async function completePasskeySignIn(auth, result) {
|
|
5
|
+
if (!result?.accessToken) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"[glide-firebase] Passkey result has no accessToken to exchange. Wire your server's onAuthSuccess to createFirebaseBridge() so it mints and returns a Firebase custom token."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return signInWithCustomToken(auth, result.accessToken);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { completePasskeySignIn };
|
|
14
|
+
//# sourceMappingURL=out.js.map
|
|
15
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":";AAuBA,SAAS,6BAA6B;AAgBtC,eAAsB,sBACpB,MACA,QACyB;AACzB,MAAI,CAAC,QAAQ,aAAa;AACxB,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACA,SAAO,sBAAsB,MAAM,OAAO,WAAW;AACvD","sourcesContent":["/**\n * Client helper: exchange a Glide passkey result for a Firebase session.\n *\n * After a passkey ceremony succeeds, Glide's `onSuccess(result)` carries the\n * minted Firebase custom token on `result.accessToken` (because the server\n * bridge returned it). Hand that to Firebase and you get a normal signed-in\n * session — `onIdTokenChanged` fires and the rest of your app proceeds exactly\n * as if the user had signed in any other way.\n *\n * @example\n * ```ts\n * import { getAuth } from \"firebase/auth\";\n * import { completePasskeySignIn } from \"@glydi/passkey-firebase/client\";\n *\n * <PasskeyButton\n * mode=\"signin\"\n * endpoints={{ ... }}\n * fetchOptions={{ credentials: \"include\" }}\n * onSuccess={(result) => completePasskeySignIn(getAuth(), result)}\n * />\n * ```\n */\n\nimport { signInWithCustomToken } from \"firebase/auth\";\nimport type { Auth, UserCredential } from \"firebase/auth\";\n\n/** Minimal shape of Glide's `AuthResult` this helper needs. */\nexport interface PasskeyResultLike {\n /** Firebase custom token minted by the server bridge (`createFirebaseBridge`). */\n accessToken?: string;\n}\n\n/**\n * Complete a Glide passkey sign-in by exchanging the minted custom token for a\n * Firebase session via `signInWithCustomToken`.\n *\n * @throws if `result.accessToken` is missing — almost always means the server\n * side isn't wired to `createFirebaseBridge` (so no token was returned).\n */\nexport async function completePasskeySignIn(\n auth: Auth,\n result: PasskeyResultLike,\n): Promise<UserCredential> {\n if (!result?.accessToken) {\n throw new Error(\n \"[glide-firebase] Passkey result has no accessToken to exchange. Wire your \" +\n \"server's onAuthSuccess to createFirebaseBridge() so it mints and returns \" +\n \"a Firebase custom token.\",\n );\n }\n return signInWithCustomToken(auth, result.accessToken);\n}\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { RequestHandler, Request as Request$1, Response as Response$1, Router } from 'express';
|
|
2
|
+
import { createGlideServer } from '@glydi/passkey-server';
|
|
3
|
+
import { Auth } from 'firebase-admin/auth';
|
|
4
|
+
import { GlideBridgeUser } from './server.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Express adapter for Glide's Web-standard route handler.
|
|
8
|
+
*
|
|
9
|
+
* `@glydi/passkey-server` ships a single framework-neutral
|
|
10
|
+
* `(request: Request) => Promise<Response>` handler. Express 5 speaks Node
|
|
11
|
+
* `req`/`res`, not Web `Request`/`Response`, so this shim bridges the two:
|
|
12
|
+
* Express req → Web Request → [Glide handler] → Web Response → Express res
|
|
13
|
+
*
|
|
14
|
+
* It preserves status, headers, and — crucially — multiple `Set-Cookie` headers
|
|
15
|
+
* (the `glide_sid` pre-auth cookie rides here).
|
|
16
|
+
*
|
|
17
|
+
* Body: the adapter expects a JSON body parser (e.g. `express.json()`) to have
|
|
18
|
+
* populated `req.body`, which is the norm — the Glide client always POSTs
|
|
19
|
+
* `content-type: application/json`. The parsed body is re-serialized into the
|
|
20
|
+
* Web Request so the handler's `request.json()` works.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { Router } from "express";
|
|
25
|
+
* import { createPasskeyRouteHandler } from "@glydi/passkey-server";
|
|
26
|
+
* import { toExpressHandler } from "@glydi/passkey-firebase/express";
|
|
27
|
+
*
|
|
28
|
+
* const glideHandler = toExpressHandler(createPasskeyRouteHandler({ ... }));
|
|
29
|
+
* const router = Router();
|
|
30
|
+
* router.post("/authenticate-begin", glideHandler);
|
|
31
|
+
* router.post("/authenticate-finish", glideHandler);
|
|
32
|
+
* router.post("/register-begin", requireAuth, glideHandler);
|
|
33
|
+
* router.post("/register-finish", requireAuth, glideHandler);
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The Web-standard handler returned by `createPasskeyRouteHandler`. */
|
|
38
|
+
type GlideRequestHandler = (request: Request) => Promise<Response>;
|
|
39
|
+
interface ExpressAdapterOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Absolute base origin used to construct the Web Request URL (the `Request`
|
|
42
|
+
* constructor requires an absolute URL). Glide only reads the *last* path
|
|
43
|
+
* segment, so the host is cosmetic — but it must parse. Defaults to inferring
|
|
44
|
+
* `${req.protocol}://${req.get("host")}` per request. Override when behind a
|
|
45
|
+
* proxy that rewrites the path.
|
|
46
|
+
*/
|
|
47
|
+
origin?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wrap Glide's Web-standard handler as an Express `(req, res)` handler.
|
|
51
|
+
*/
|
|
52
|
+
declare function toExpressHandler(handler: GlideRequestHandler, options?: ExpressAdapterOptions): (req: Request$1, res: Response$1) => Promise<void>;
|
|
53
|
+
/** Cookie settings for the `glide_sid` pre-auth (challenge-binding) cookie. */
|
|
54
|
+
interface GlideSidCookieOptions {
|
|
55
|
+
/** Cookie name. Default `"glide_sid"`. */
|
|
56
|
+
name?: string;
|
|
57
|
+
/** `Secure` flag. Default `true` (required when `sameSite: "none"`). */
|
|
58
|
+
secure?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* `SameSite`. Default `"none"` (prod cross-site). Use `"lax"` for same-site
|
|
61
|
+
* local dev over http (where a `Secure` `None` cookie won't be set).
|
|
62
|
+
*/
|
|
63
|
+
sameSite?: "lax" | "none" | "strict";
|
|
64
|
+
/** Optional `Domain` (e.g. `.example.com` to share across subdomains). */
|
|
65
|
+
domain?: string;
|
|
66
|
+
/** `Path`. Default `"/"`. */
|
|
67
|
+
path?: string;
|
|
68
|
+
/** Lifetime in ms. Default 10 minutes — only needs to outlive one ceremony. */
|
|
69
|
+
maxAgeMs?: number;
|
|
70
|
+
}
|
|
71
|
+
interface ExpressPasskeyRouterOptions {
|
|
72
|
+
/** The GlideServer from `createGlideServer()`. */
|
|
73
|
+
server: ReturnType<typeof createGlideServer>;
|
|
74
|
+
/** firebase-admin `Auth` (e.g. `getAuth()`) used to mint the custom token. */
|
|
75
|
+
auth: Auth;
|
|
76
|
+
/**
|
|
77
|
+
* Your existing auth middleware. Gates the `register-*` (add-a-passkey)
|
|
78
|
+
* routes so a passkey can only be attached by an already-signed-in user.
|
|
79
|
+
*/
|
|
80
|
+
requireAuth: RequestHandler;
|
|
81
|
+
/**
|
|
82
|
+
* Pull the verified Firebase UID out of the request *after* `requireAuth`
|
|
83
|
+
* ran — e.g. `(_, res) => res.locals.user?.uid`. The new credential is bound
|
|
84
|
+
* to this UID, so your GlideStore's `user.id` must equal the Firebase UID.
|
|
85
|
+
*/
|
|
86
|
+
getUserId: (req: Request$1, res: Response$1) => string | undefined | Promise<string | undefined>;
|
|
87
|
+
/** `glide_sid` cookie options (see {@link GlideSidCookieOptions}). */
|
|
88
|
+
cookie?: GlideSidCookieOptions;
|
|
89
|
+
/** Extra Firebase custom claims to embed in the minted login token. */
|
|
90
|
+
developerClaims?: (user: GlideBridgeUser) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
91
|
+
/** Response field carrying the minted token. Default `"accessToken"`. */
|
|
92
|
+
tokenField?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build an Express `Router` exposing the four passkey ceremonies for a Firebase
|
|
96
|
+
* app. Mount it under your passkey path (e.g. `/api/v1/passkey`):
|
|
97
|
+
*
|
|
98
|
+
* - `POST /register-begin` · `POST /register-finish` — gated by `requireAuth`;
|
|
99
|
+
* attaches a new passkey to the signed-in Firebase UID.
|
|
100
|
+
* - `POST /authenticate-begin` · `POST /authenticate-finish` — public; on success
|
|
101
|
+
* mints a Firebase custom token and returns it as `accessToken` for the client
|
|
102
|
+
* to exchange via `signInWithCustomToken`.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* import { getAuth } from "firebase-admin/auth";
|
|
107
|
+
* import { createGlideServer } from "@glydi/passkey-server";
|
|
108
|
+
* import { createExpressPasskeyRouter } from "@glydi/passkey-firebase/express";
|
|
109
|
+
*
|
|
110
|
+
* const glide = createGlideServer({ rpName, rpID, origin, store });
|
|
111
|
+
* app.use("/api/v1/passkey", createExpressPasskeyRouter({
|
|
112
|
+
* server: glide,
|
|
113
|
+
* auth: getAuth(),
|
|
114
|
+
* requireAuth,
|
|
115
|
+
* getUserId: (_req, res) => res.locals.user?.uid,
|
|
116
|
+
* cookie: { secure: isProd, sameSite: isProd ? "none" : "lax" },
|
|
117
|
+
* }));
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
declare function createExpressPasskeyRouter(options: ExpressPasskeyRouterOptions): Router;
|
|
121
|
+
|
|
122
|
+
export { type ExpressAdapterOptions, type ExpressPasskeyRouterOptions, type GlideRequestHandler, type GlideSidCookieOptions, createExpressPasskeyRouter, toExpressHandler };
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createFirebaseBridge } from './chunk-JP6DMV6X.js';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { GlideServerError } from '@glydi/passkey-server';
|
|
5
|
+
|
|
6
|
+
function toWebRequest(req, origin) {
|
|
7
|
+
const base = origin ?? `${req.protocol}://${req.get("host") ?? "localhost"}`;
|
|
8
|
+
const url = new URL(req.originalUrl, base).toString();
|
|
9
|
+
const headers = new Headers();
|
|
10
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
11
|
+
if (value === void 0)
|
|
12
|
+
continue;
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
for (const v of value)
|
|
15
|
+
headers.append(key, v);
|
|
16
|
+
} else {
|
|
17
|
+
headers.set(key, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
let body;
|
|
21
|
+
if (req.body !== void 0 && req.body !== null) {
|
|
22
|
+
body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
23
|
+
if (!headers.has("content-type")) {
|
|
24
|
+
headers.set("content-type", "application/json");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const init = { method: req.method, headers };
|
|
28
|
+
if (body !== void 0 && req.method !== "GET" && req.method !== "HEAD") {
|
|
29
|
+
init.body = body;
|
|
30
|
+
}
|
|
31
|
+
return new Request(url, init);
|
|
32
|
+
}
|
|
33
|
+
async function writeWebResponse(webResponse, res) {
|
|
34
|
+
res.status(webResponse.status);
|
|
35
|
+
const setCookies = typeof webResponse.headers.getSetCookie === "function" ? webResponse.headers.getSetCookie() : [];
|
|
36
|
+
webResponse.headers.forEach((value, key) => {
|
|
37
|
+
if (key.toLowerCase() === "set-cookie")
|
|
38
|
+
return;
|
|
39
|
+
res.setHeader(key, value);
|
|
40
|
+
});
|
|
41
|
+
for (const cookie of setCookies) {
|
|
42
|
+
res.appendHeader("set-cookie", cookie);
|
|
43
|
+
}
|
|
44
|
+
const buf = Buffer.from(await webResponse.arrayBuffer());
|
|
45
|
+
res.end(buf);
|
|
46
|
+
}
|
|
47
|
+
function toExpressHandler(handler, options = {}) {
|
|
48
|
+
return async (req, res) => {
|
|
49
|
+
const webRequest = toWebRequest(req, options.origin);
|
|
50
|
+
const webResponse = await handler(webRequest);
|
|
51
|
+
await writeWebResponse(webResponse, res);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function parseCookie(header, name) {
|
|
55
|
+
if (!header)
|
|
56
|
+
return void 0;
|
|
57
|
+
for (const part of header.split(";")) {
|
|
58
|
+
const eq = part.indexOf("=");
|
|
59
|
+
if (eq === -1)
|
|
60
|
+
continue;
|
|
61
|
+
if (part.slice(0, eq).trim() === name) {
|
|
62
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
function readOrSetSid(req, res, opts) {
|
|
68
|
+
const name = opts.name ?? "glide_sid";
|
|
69
|
+
const existing = parseCookie(req.headers.cookie, name);
|
|
70
|
+
if (existing)
|
|
71
|
+
return existing;
|
|
72
|
+
const sid = randomUUID();
|
|
73
|
+
res.cookie(name, sid, {
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
secure: opts.secure ?? true,
|
|
76
|
+
sameSite: opts.sameSite ?? "none",
|
|
77
|
+
...opts.domain ? { domain: opts.domain } : {},
|
|
78
|
+
path: opts.path ?? "/",
|
|
79
|
+
maxAge: opts.maxAgeMs ?? 10 * 60 * 1e3
|
|
80
|
+
});
|
|
81
|
+
return sid;
|
|
82
|
+
}
|
|
83
|
+
function readSid(req, opts) {
|
|
84
|
+
return parseCookie(req.headers.cookie, opts.name ?? "glide_sid");
|
|
85
|
+
}
|
|
86
|
+
function guarded(fn) {
|
|
87
|
+
return async (req, res) => {
|
|
88
|
+
try {
|
|
89
|
+
await fn(req, res);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err instanceof GlideServerError) {
|
|
92
|
+
res.status(400).json({ error: err.code, message: err.message });
|
|
93
|
+
} else {
|
|
94
|
+
console.error("[glide-firebase] unexpected error", err);
|
|
95
|
+
res.status(500).json({ error: "internal" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function createExpressPasskeyRouter(options) {
|
|
101
|
+
const router = Router();
|
|
102
|
+
const cookieOpts = options.cookie ?? {};
|
|
103
|
+
const mint = createFirebaseBridge({
|
|
104
|
+
auth: options.auth,
|
|
105
|
+
...options.developerClaims ? { developerClaims: options.developerClaims } : {},
|
|
106
|
+
...options.tokenField ? { tokenField: options.tokenField } : {}
|
|
107
|
+
});
|
|
108
|
+
router.post(
|
|
109
|
+
"/register-begin",
|
|
110
|
+
options.requireAuth,
|
|
111
|
+
guarded(async (req, res) => {
|
|
112
|
+
const sessionId = readOrSetSid(req, res, cookieOpts);
|
|
113
|
+
const userId = await options.getUserId(req, res);
|
|
114
|
+
if (!userId) {
|
|
115
|
+
res.status(401).json({ error: "unauthenticated" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
res.json(await options.server.registerBegin({ sessionId, userId }));
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
router.post(
|
|
122
|
+
"/register-finish",
|
|
123
|
+
options.requireAuth,
|
|
124
|
+
guarded(async (req, res) => {
|
|
125
|
+
const sessionId = readSid(req, cookieOpts);
|
|
126
|
+
if (!sessionId)
|
|
127
|
+
throw new GlideServerError("no_challenge", "No active challenge.");
|
|
128
|
+
res.json(await options.server.registerFinish({ sessionId, body: req.body }));
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
router.post(
|
|
132
|
+
"/authenticate-begin",
|
|
133
|
+
guarded(async (req, res) => {
|
|
134
|
+
const sessionId = readOrSetSid(req, res, cookieOpts);
|
|
135
|
+
const username = typeof req.body?.username === "string" ? req.body.username : void 0;
|
|
136
|
+
res.json(
|
|
137
|
+
await options.server.authenticateBegin({
|
|
138
|
+
sessionId,
|
|
139
|
+
...username ? { username } : {}
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
router.post(
|
|
145
|
+
"/authenticate-finish",
|
|
146
|
+
guarded(async (req, res) => {
|
|
147
|
+
const sessionId = readSid(req, cookieOpts);
|
|
148
|
+
if (!sessionId)
|
|
149
|
+
throw new GlideServerError("no_challenge", "No active challenge.");
|
|
150
|
+
const result = await options.server.authenticateFinish({
|
|
151
|
+
sessionId,
|
|
152
|
+
body: req.body
|
|
153
|
+
});
|
|
154
|
+
const extra = await mint(result.user);
|
|
155
|
+
res.json({ ...result, ...extra });
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
return router;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { createExpressPasskeyRouter, toExpressHandler };
|
|
162
|
+
//# sourceMappingURL=out.js.map
|
|
163
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/express.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Express adapter for Glide's Web-standard route handler.\n *\n * `@glydi/passkey-server` ships a single framework-neutral\n * `(request: Request) => Promise<Response>` handler. Express 5 speaks Node\n * `req`/`res`, not Web `Request`/`Response`, so this shim bridges the two:\n * Express req → Web Request → [Glide handler] → Web Response → Express res\n *\n * It preserves status, headers, and — crucially — multiple `Set-Cookie` headers\n * (the `glide_sid` pre-auth cookie rides here).\n *\n * Body: the adapter expects a JSON body parser (e.g. `express.json()`) to have\n * populated `req.body`, which is the norm — the Glide client always POSTs\n * `content-type: application/json`. The parsed body is re-serialized into the\n * Web Request so the handler's `request.json()` works.\n *\n * @example\n * ```ts\n * import { Router } from \"express\";\n * import { createPasskeyRouteHandler } from \"@glydi/passkey-server\";\n * import { toExpressHandler } from \"@glydi/passkey-firebase/express\";\n *\n * const glideHandler = toExpressHandler(createPasskeyRouteHandler({ ... }));\n * const router = Router();\n * router.post(\"/authenticate-begin\", glideHandler);\n * router.post(\"/authenticate-finish\", glideHandler);\n * router.post(\"/register-begin\", requireAuth, glideHandler);\n * router.post(\"/register-finish\", requireAuth, glideHandler);\n * ```\n */\n\nimport { Router } from \"express\";\nimport type {\n Request as ExpressRequest,\n Response as ExpressResponse,\n RequestHandler,\n} from \"express\";\nimport { randomUUID } from \"node:crypto\";\nimport { GlideServerError } from \"@glydi/passkey-server\";\nimport type { createGlideServer } from \"@glydi/passkey-server\";\nimport type { Auth } from \"firebase-admin/auth\";\nimport { createFirebaseBridge, type GlideBridgeUser } from \"./server.js\";\n\n/** The Web-standard handler returned by `createPasskeyRouteHandler`. */\nexport type GlideRequestHandler = (request: Request) => Promise<Response>;\n\nexport interface ExpressAdapterOptions {\n /**\n * Absolute base origin used to construct the Web Request URL (the `Request`\n * constructor requires an absolute URL). Glide only reads the *last* path\n * segment, so the host is cosmetic — but it must parse. Defaults to inferring\n * `${req.protocol}://${req.get(\"host\")}` per request. Override when behind a\n * proxy that rewrites the path.\n */\n origin?: string;\n}\n\nfunction toWebRequest(req: ExpressRequest, origin?: string): Request {\n const base =\n origin ?? `${req.protocol}://${req.get(\"host\") ?? \"localhost\"}`;\n // originalUrl keeps the full mounted path (e.g. /api/v1/passkey/register-begin)\n // so the handler's last-segment dispatch resolves the ceremony correctly.\n const url = new URL(req.originalUrl, base).toString();\n\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) continue;\n if (Array.isArray(value)) {\n for (const v of value) headers.append(key, v);\n } else {\n headers.set(key, value);\n }\n }\n\n // Re-serialize the parsed JSON body. `req.body` is `{}` for empty POSTs under\n // express.json(); Glide's safeJson tolerates `{}`.\n let body: string | undefined;\n if (req.body !== undefined && req.body !== null) {\n body = typeof req.body === \"string\" ? req.body : JSON.stringify(req.body);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n }\n\n const init: RequestInit = { method: req.method, headers };\n // GET/HEAD must not carry a body; Glide routes are POST-only anyway.\n if (body !== undefined && req.method !== \"GET\" && req.method !== \"HEAD\") {\n init.body = body;\n }\n\n return new Request(url, init);\n}\n\nasync function writeWebResponse(\n webResponse: Response,\n res: ExpressResponse,\n): Promise<void> {\n res.status(webResponse.status);\n\n // Multiple Set-Cookie headers must be emitted individually. The Web Headers\n // object collapses them on iteration, so pull them via getSetCookie() and skip\n // set-cookie in the generic copy below.\n const setCookies =\n typeof webResponse.headers.getSetCookie === \"function\"\n ? webResponse.headers.getSetCookie()\n : [];\n\n webResponse.headers.forEach((value, key) => {\n if (key.toLowerCase() === \"set-cookie\") return;\n res.setHeader(key, value);\n });\n for (const cookie of setCookies) {\n res.appendHeader(\"set-cookie\", cookie);\n }\n\n const buf = Buffer.from(await webResponse.arrayBuffer());\n res.end(buf);\n}\n\n/**\n * Wrap Glide's Web-standard handler as an Express `(req, res)` handler.\n */\nexport function toExpressHandler(\n handler: GlideRequestHandler,\n options: ExpressAdapterOptions = {},\n): (req: ExpressRequest, res: ExpressResponse) => Promise<void> {\n return async (req, res) => {\n const webRequest = toWebRequest(req, options.origin);\n const webResponse = await handler(webRequest);\n await writeWebResponse(webResponse, res);\n };\n}\n\n// ===========================================================================\n// High-level: Express-native passkey router for Firebase apps\n// ===========================================================================\n//\n// `toExpressHandler` adapts Glide's Web route handler, but Express integrators\n// hit two papercuts it can't solve from the Web layer: (1) the verified user\n// lives on `res.locals`, invisible to a Web Request; (2) the pre-auth session\n// cookie must be *set* on the Express response. `createExpressPasskeyRouter`\n// owns both — plus per-action auth and the Firebase token mint — so wiring a\n// Firebase app is a handful of lines.\n\n/** Cookie settings for the `glide_sid` pre-auth (challenge-binding) cookie. */\nexport interface GlideSidCookieOptions {\n /** Cookie name. Default `\"glide_sid\"`. */\n name?: string;\n /** `Secure` flag. Default `true` (required when `sameSite: \"none\"`). */\n secure?: boolean;\n /**\n * `SameSite`. Default `\"none\"` (prod cross-site). Use `\"lax\"` for same-site\n * local dev over http (where a `Secure` `None` cookie won't be set).\n */\n sameSite?: \"lax\" | \"none\" | \"strict\";\n /** Optional `Domain` (e.g. `.example.com` to share across subdomains). */\n domain?: string;\n /** `Path`. Default `\"/\"`. */\n path?: string;\n /** Lifetime in ms. Default 10 minutes — only needs to outlive one ceremony. */\n maxAgeMs?: number;\n}\n\nexport interface ExpressPasskeyRouterOptions {\n /** The GlideServer from `createGlideServer()`. */\n server: ReturnType<typeof createGlideServer>;\n /** firebase-admin `Auth` (e.g. `getAuth()`) used to mint the custom token. */\n auth: Auth;\n /**\n * Your existing auth middleware. Gates the `register-*` (add-a-passkey)\n * routes so a passkey can only be attached by an already-signed-in user.\n */\n requireAuth: RequestHandler;\n /**\n * Pull the verified Firebase UID out of the request *after* `requireAuth`\n * ran — e.g. `(_, res) => res.locals.user?.uid`. The new credential is bound\n * to this UID, so your GlideStore's `user.id` must equal the Firebase UID.\n */\n getUserId: (\n req: ExpressRequest,\n res: ExpressResponse,\n ) => string | undefined | Promise<string | undefined>;\n /** `glide_sid` cookie options (see {@link GlideSidCookieOptions}). */\n cookie?: GlideSidCookieOptions;\n /** Extra Firebase custom claims to embed in the minted login token. */\n developerClaims?: (\n user: GlideBridgeUser,\n ) => Record<string, unknown> | Promise<Record<string, unknown>>;\n /** Response field carrying the minted token. Default `\"accessToken\"`. */\n tokenField?: string;\n}\n\nfunction parseCookie(header: string | undefined, name: string): string | undefined {\n if (!header) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) {\n return decodeURIComponent(part.slice(eq + 1).trim());\n }\n }\n return undefined;\n}\n\nfunction readOrSetSid(\n req: ExpressRequest,\n res: ExpressResponse,\n opts: GlideSidCookieOptions,\n): string {\n const name = opts.name ?? \"glide_sid\";\n const existing = parseCookie(req.headers.cookie, name);\n if (existing) return existing;\n const sid = randomUUID();\n res.cookie(name, sid, {\n httpOnly: true,\n secure: opts.secure ?? true,\n sameSite: opts.sameSite ?? \"none\",\n ...(opts.domain ? { domain: opts.domain } : {}),\n path: opts.path ?? \"/\",\n maxAge: opts.maxAgeMs ?? 10 * 60 * 1000,\n });\n return sid;\n}\n\nfunction readSid(req: ExpressRequest, opts: GlideSidCookieOptions): string | undefined {\n return parseCookie(req.headers.cookie, opts.name ?? \"glide_sid\");\n}\n\n/** Wrap an async route, mapping GlideServerError → 400 and anything else → 500. */\nfunction guarded(\n fn: (req: ExpressRequest, res: ExpressResponse) => Promise<void>,\n): (req: ExpressRequest, res: ExpressResponse) => Promise<void> {\n return async (req, res) => {\n try {\n await fn(req, res);\n } catch (err) {\n if (err instanceof GlideServerError) {\n res.status(400).json({ error: err.code, message: err.message });\n } else {\n console.error(\"[glide-firebase] unexpected error\", err);\n res.status(500).json({ error: \"internal\" });\n }\n }\n };\n}\n\n/**\n * Build an Express `Router` exposing the four passkey ceremonies for a Firebase\n * app. Mount it under your passkey path (e.g. `/api/v1/passkey`):\n *\n * - `POST /register-begin` · `POST /register-finish` — gated by `requireAuth`;\n * attaches a new passkey to the signed-in Firebase UID.\n * - `POST /authenticate-begin` · `POST /authenticate-finish` — public; on success\n * mints a Firebase custom token and returns it as `accessToken` for the client\n * to exchange via `signInWithCustomToken`.\n *\n * @example\n * ```ts\n * import { getAuth } from \"firebase-admin/auth\";\n * import { createGlideServer } from \"@glydi/passkey-server\";\n * import { createExpressPasskeyRouter } from \"@glydi/passkey-firebase/express\";\n *\n * const glide = createGlideServer({ rpName, rpID, origin, store });\n * app.use(\"/api/v1/passkey\", createExpressPasskeyRouter({\n * server: glide,\n * auth: getAuth(),\n * requireAuth,\n * getUserId: (_req, res) => res.locals.user?.uid,\n * cookie: { secure: isProd, sameSite: isProd ? \"none\" : \"lax\" },\n * }));\n * ```\n */\nexport function createExpressPasskeyRouter(\n options: ExpressPasskeyRouterOptions,\n): Router {\n const router = Router();\n const cookieOpts = options.cookie ?? {};\n const mint = createFirebaseBridge({\n auth: options.auth,\n ...(options.developerClaims ? { developerClaims: options.developerClaims } : {}),\n ...(options.tokenField ? { tokenField: options.tokenField } : {}),\n });\n\n // ── Register (add-a-passkey): behind the host's auth; links to signed-in UID ──\n router.post(\n \"/register-begin\",\n options.requireAuth,\n guarded(async (req, res) => {\n const sessionId = readOrSetSid(req, res, cookieOpts);\n const userId = await options.getUserId(req, res);\n if (!userId) {\n res.status(401).json({ error: \"unauthenticated\" });\n return;\n }\n res.json(await options.server.registerBegin({ sessionId, userId }));\n }),\n );\n\n router.post(\n \"/register-finish\",\n options.requireAuth,\n guarded(async (req, res) => {\n const sessionId = readSid(req, cookieOpts);\n if (!sessionId) throw new GlideServerError(\"no_challenge\", \"No active challenge.\");\n // User already holds a Firebase session here — no token to mint.\n res.json(await options.server.registerFinish({ sessionId, body: req.body }));\n }),\n );\n\n // ── Authenticate (login): public — the passkey IS the proof. Mints a token. ──\n router.post(\n \"/authenticate-begin\",\n guarded(async (req, res) => {\n const sessionId = readOrSetSid(req, res, cookieOpts);\n const username =\n typeof req.body?.username === \"string\" ? req.body.username : undefined;\n res.json(\n await options.server.authenticateBegin({\n sessionId,\n ...(username ? { username } : {}),\n }),\n );\n }),\n );\n\n router.post(\n \"/authenticate-finish\",\n guarded(async (req, res) => {\n const sessionId = readSid(req, cookieOpts);\n if (!sessionId) throw new GlideServerError(\"no_challenge\", \"No active challenge.\");\n const result = await options.server.authenticateFinish({\n sessionId,\n body: req.body,\n });\n const extra = await mint(result.user);\n res.json({ ...result, ...extra });\n }),\n );\n\n return router;\n}\n"]}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Auth } from 'firebase-admin/auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server bridge: Glide passkey → Firebase custom token.
|
|
5
|
+
*
|
|
6
|
+
* Glide's WebAuthn ceremony verifies the user and hands you `{ user }` (where
|
|
7
|
+
* `user.id` is whatever id your store assigned — set it to the Firebase UID).
|
|
8
|
+
* This bridge turns that verified user into a *Firebase custom token* by calling
|
|
9
|
+
* `admin.auth().createCustomToken(uid)`, and returns it shaped so Glide's route
|
|
10
|
+
* handler merges it into the JSON response (`{ accessToken }`). The browser then
|
|
11
|
+
* exchanges it via `signInWithCustomToken` (see `@glydi/passkey-firebase/client`).
|
|
12
|
+
*
|
|
13
|
+
* The net effect: a passkey login becomes *another door into the same Firebase
|
|
14
|
+
* session* — your existing `verifyIdToken` middleware, custom-claim roles, and
|
|
15
|
+
* user records keep working untouched.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { getAuth } from "firebase-admin/auth";
|
|
20
|
+
* import { createPasskeyRouteHandler } from "@glydi/passkey-server";
|
|
21
|
+
* import { createFirebaseBridge } from "@glydi/passkey-firebase/server";
|
|
22
|
+
*
|
|
23
|
+
* const handler = createPasskeyRouteHandler({
|
|
24
|
+
* server: glide,
|
|
25
|
+
* getSessionId,
|
|
26
|
+
* getUserId, // verified Firebase UID (register-*)
|
|
27
|
+
* onAuthSuccess: createFirebaseBridge({ auth: getAuth() }),
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The authenticated-user shape Glide hands to `onAuthSuccess`. */
|
|
33
|
+
interface GlideBridgeUser {
|
|
34
|
+
/** Stable user id from your store — MUST be the Firebase UID for the bridge. */
|
|
35
|
+
id: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
}
|
|
39
|
+
interface FirebaseBridgeOptions {
|
|
40
|
+
/**
|
|
41
|
+
* A firebase-admin `Auth` instance, e.g. `getAuth()` from "firebase-admin/auth".
|
|
42
|
+
* Injected (not imported) so this package never bundles firebase-admin and you
|
|
43
|
+
* keep a single initialized Admin app.
|
|
44
|
+
*/
|
|
45
|
+
auth: Auth;
|
|
46
|
+
/**
|
|
47
|
+
* Optional: extra Firebase custom claims to embed in *this* minted token.
|
|
48
|
+
* Receives the Glide user (`user.id` === Firebase UID). Useful for one-shot
|
|
49
|
+
* claims; for durable role claims prefer `auth.setCustomUserClaims(uid, ...)`
|
|
50
|
+
* in your own onboarding flow.
|
|
51
|
+
*/
|
|
52
|
+
developerClaims?: (user: GlideBridgeUser) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
53
|
+
/**
|
|
54
|
+
* Response field that carries the minted token to the client.
|
|
55
|
+
* Defaults to `"accessToken"` (already typed on Glide's `AuthResult`, so the
|
|
56
|
+
* client helper and React `onSuccess` see it with no extra wiring).
|
|
57
|
+
*/
|
|
58
|
+
tokenField?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build an `onAuthSuccess` callback for `createPasskeyRouteHandler` that mints a
|
|
62
|
+
* Firebase custom token for the verified user and returns it in the response body.
|
|
63
|
+
*/
|
|
64
|
+
declare function createFirebaseBridge(options: FirebaseBridgeOptions): (user: GlideBridgeUser, request?: unknown) => Promise<Record<string, string>>;
|
|
65
|
+
|
|
66
|
+
export { type FirebaseBridgeOptions, type GlideBridgeUser, createFirebaseBridge };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glydi/passkey-firebase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Drop Glide passkeys onto an app that already uses Firebase Auth. Mints a Firebase custom token from a verified passkey, plus an Express adapter and a client sign-in helper.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./server": {
|
|
11
|
+
"types": "./dist/server.d.ts",
|
|
12
|
+
"import": "./dist/server.js"
|
|
13
|
+
},
|
|
14
|
+
"./express": {
|
|
15
|
+
"types": "./dist/express.d.ts",
|
|
16
|
+
"import": "./dist/express.js"
|
|
17
|
+
},
|
|
18
|
+
"./client": {
|
|
19
|
+
"types": "./dist/client.d.ts",
|
|
20
|
+
"import": "./dist/client.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"express": ">=4",
|
|
28
|
+
"firebase": ">=10",
|
|
29
|
+
"firebase-admin": ">=12",
|
|
30
|
+
"@glydi/passkey-server": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"express": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"firebase": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"firebase-admin": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/express": "^5.0.0",
|
|
45
|
+
"@types/node": "^20.0.0",
|
|
46
|
+
"express": "^5.0.0",
|
|
47
|
+
"firebase": "^11.0.0",
|
|
48
|
+
"firebase-admin": "^13.0.0",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"typescript": "^5.4.0",
|
|
51
|
+
"@glydi/passkey-server": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"dev": "tsup --watch",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"clean": "rm -rf dist .turbo"
|
|
59
|
+
}
|
|
60
|
+
}
|