@lastshotlabs/bunshot 0.0.21 → 0.0.25
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 +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +44 -0
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +144 -0
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +120 -0
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +7 -0
- package/dist/adapters/sqliteAuth.js +199 -0
- package/dist/app.d.ts +100 -3
- package/dist/app.js +247 -46
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +49 -7
- package/dist/index.js +35 -5
- package/dist/lib/HttpError.d.ts +5 -0
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.d.ts +44 -0
- package/dist/lib/appConfig.js +16 -0
- package/dist/lib/auditLog.d.ts +52 -0
- package/dist/lib/auditLog.js +201 -0
- package/dist/lib/authAdapter.d.ts +69 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +19 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/session.d.ts +4 -0
- package/dist/lib/session.js +56 -2
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +180 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +35 -0
- package/dist/lib/upload.js +87 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +21 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/csrf.js +10 -0
- package/dist/middleware/identify.js +57 -9
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +99 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +36 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +57 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/routes/auth.js +84 -6
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +47 -45
- package/dist/routes/metrics.d.ts +7 -0
- package/dist/routes/metrics.js +52 -0
- package/dist/routes/mfa.js +4 -0
- package/dist/routes/uploads.d.ts +2 -0
- package/dist/routes/uploads.js +135 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/ws/index.js +3 -0
- package/docs/sections/auth-flow/full.md +779 -634
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +365 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +127 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +199 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +83 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +16 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
## Request Logging & Request IDs
|
|
2
|
+
|
|
3
|
+
Every request gets a unique **request ID** — either read from the incoming `X-Request-Id` header or generated as a `crypto.randomUUID()`. The ID is:
|
|
4
|
+
|
|
5
|
+
- Set on context: `c.get("requestId")`
|
|
6
|
+
- Echoed back in the `X-Request-Id` response header
|
|
7
|
+
- Included in error JSON responses (`{ error: "...", requestId: "..." }`)
|
|
8
|
+
- Included in audit log entries (when audit logging is enabled)
|
|
9
|
+
|
|
10
|
+
### Structured JSON Logging
|
|
11
|
+
|
|
12
|
+
By default, every request/response is logged as structured JSON (replacing Hono's built-in text logger):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"level": "info",
|
|
17
|
+
"time": 1710000000000,
|
|
18
|
+
"msg": "GET /api/users 200",
|
|
19
|
+
"requestId": "550e8400-e29b-41d4-a716-446655440000",
|
|
20
|
+
"method": "GET",
|
|
21
|
+
"path": "/api/users",
|
|
22
|
+
"statusCode": 200,
|
|
23
|
+
"responseTime": 12.34,
|
|
24
|
+
"ip": "203.0.113.42",
|
|
25
|
+
"userAgent": "Mozilla/5.0...",
|
|
26
|
+
"userId": "user-123",
|
|
27
|
+
"sessionId": "sess-456",
|
|
28
|
+
"tenantId": "tenant-789"
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Status codes map to log levels: 5xx → `"error"`, 4xx → `"warn"`, else `"info"`.
|
|
33
|
+
|
|
34
|
+
Default excluded paths: `/health`, `/docs`, `/openapi.json` (prefix matching).
|
|
35
|
+
|
|
36
|
+
### Configuration
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
await createServer({
|
|
40
|
+
routesDir: import.meta.dir + "/routes",
|
|
41
|
+
logging: {
|
|
42
|
+
// Default: true. Set false to disable logging entirely (no fallback to Hono's text logger)
|
|
43
|
+
enabled: true,
|
|
44
|
+
// Minimum level to emit — "info" (default), "warn", or "error"
|
|
45
|
+
level: "info",
|
|
46
|
+
// Custom log handler — plug in Pino, Datadog, etc.
|
|
47
|
+
onLog: (entry) => pino.info(entry),
|
|
48
|
+
// Paths to exclude (prefix matching for strings, .test() for RegExp)
|
|
49
|
+
excludePaths: ["/health", "/docs", "/openapi.json", /^\/internal\//],
|
|
50
|
+
// HTTP methods to exclude
|
|
51
|
+
excludeMethods: ["OPTIONS"],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Using Request IDs
|
|
57
|
+
|
|
58
|
+
Access the request ID in any route or middleware:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
router.get("/api/example", (c) => {
|
|
62
|
+
const requestId = c.get("requestId");
|
|
63
|
+
// Pass to downstream services, include in logs, etc.
|
|
64
|
+
return c.json({ requestId, data: "..." });
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Exports
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
requestId, // middleware — generates/propagates X-Request-Id
|
|
73
|
+
requestLogger, // middleware — structured JSON request logging
|
|
74
|
+
HEADER_REQUEST_ID, // "x-request-id" constant
|
|
75
|
+
} from "@lastshotlabs/bunshot";
|
|
76
|
+
|
|
77
|
+
import type {
|
|
78
|
+
RequestLogEntry, // shape of each log entry
|
|
79
|
+
RequestLoggerOptions, // options for requestLogger()
|
|
80
|
+
LogLevel, // "info" | "warn" | "error"
|
|
81
|
+
LoggingConfig, // config shape for createApp/createServer
|
|
82
|
+
} from "@lastshotlabs/bunshot";
|
|
83
|
+
```
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
## Prometheus Metrics
|
|
2
|
+
|
|
3
|
+
Opt-in `/metrics` endpoint that Prometheus can scrape. Tracks request counts, latency distributions, and optionally BullMQ queue depths.
|
|
4
|
+
|
|
5
|
+
### Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
await createServer({
|
|
9
|
+
routesDir: import.meta.dir + "/routes",
|
|
10
|
+
metrics: {
|
|
11
|
+
enabled: true,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`GET /metrics` now returns Prometheus text exposition format:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
# HELP http_requests_total Total http requests total
|
|
20
|
+
# TYPE http_requests_total counter
|
|
21
|
+
http_requests_total{method="GET",path="/users",status="200"} 42
|
|
22
|
+
|
|
23
|
+
# HELP http_request_duration_seconds http request duration seconds
|
|
24
|
+
# TYPE http_request_duration_seconds histogram
|
|
25
|
+
http_request_duration_seconds_bucket{method="GET",path="/users",le="0.005"} 10
|
|
26
|
+
...
|
|
27
|
+
http_request_duration_seconds_bucket{method="GET",path="/users",le="+Inf"} 42
|
|
28
|
+
http_request_duration_seconds_sum{method="GET",path="/users"} 12.345
|
|
29
|
+
http_request_duration_seconds_count{method="GET",path="/users"} 42
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Configuration
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
await createServer({
|
|
36
|
+
routesDir: import.meta.dir + "/routes",
|
|
37
|
+
metrics: {
|
|
38
|
+
enabled: true,
|
|
39
|
+
// Auth for /metrics — "none" (default), "userAuth", or custom middleware stack
|
|
40
|
+
auth: "userAuth",
|
|
41
|
+
// Paths excluded from collection (prefix matching for strings, .test() for RegExp)
|
|
42
|
+
excludePaths: ["/metrics", "/health", "/docs", "/openapi.json"], // defaults
|
|
43
|
+
// Custom path normalizer (default collapses UUIDs, ObjectIDs, numeric segments to :id)
|
|
44
|
+
normalizePath: (path) => path.replace(/\/[0-9]+/g, "/:id"),
|
|
45
|
+
// BullMQ queue names to report depth gauges for (requires Redis)
|
|
46
|
+
queues: ["email", "reports"],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Path Normalization
|
|
52
|
+
|
|
53
|
+
To prevent high-cardinality labels, dynamic path segments are normalized by default:
|
|
54
|
+
|
|
55
|
+
| Pattern | Example | Normalized |
|
|
56
|
+
|---------|---------|------------|
|
|
57
|
+
| UUID | `/users/550e8400-e29b-41d4-a716-446655440000` | `/users/:id` |
|
|
58
|
+
| MongoDB ObjectID | `/items/507f1f77bcf86cd799439011` | `/items/:id` |
|
|
59
|
+
| Numeric segment | `/posts/123/comments` | `/posts/:id/comments` |
|
|
60
|
+
| Short slug | `/api/v2/users` | `/api/v2/users` (unchanged) |
|
|
61
|
+
|
|
62
|
+
Override with `metrics.normalizePath` for custom normalization logic.
|
|
63
|
+
|
|
64
|
+
### Multi-Tenancy
|
|
65
|
+
|
|
66
|
+
When tenancy is configured, a `tenant` label is automatically added to all metrics:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
http_requests_total{method="GET",path="/data",status="200",tenant="acme"} 15
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### BullMQ Queue Depths
|
|
73
|
+
|
|
74
|
+
When `metrics.queues` is set, a `bullmq_queue_depth` gauge is registered and queried on each scrape:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
bullmq_queue_depth{queue="email",state="waiting"} 5
|
|
78
|
+
bullmq_queue_depth{queue="email",state="active"} 2
|
|
79
|
+
bullmq_queue_depth{queue="email",state="delayed"} 0
|
|
80
|
+
bullmq_queue_depth{queue="email",state="failed"} 1
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Queue instances are lazily cached and reused across scrapes. Call `closeMetricsQueues()` during graceful shutdown to clean up Redis connections.
|
|
84
|
+
|
|
85
|
+
### Error Handling
|
|
86
|
+
|
|
87
|
+
Gauge callback errors never cause a 500. If a gauge callback fails, the gauge is omitted from that scrape and an internal error counter is incremented:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
bunshot_gauge_errors_total{gauge="bullmq_queue_depth"} 1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This counter is always present when metrics are enabled so you can alert on scrape failures.
|
|
94
|
+
|
|
95
|
+
### Security
|
|
96
|
+
|
|
97
|
+
Auth defaults to `"none"` for easy adoption. A production warning is logged when metrics are enabled without auth. **Recommended:** Lock down `/metrics` in production since it exposes operational details (error rates, queue depths, tenant activity patterns).
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
metrics: {
|
|
101
|
+
enabled: true,
|
|
102
|
+
auth: [userAuth, requireRole("admin")], // custom middleware stack
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Self-Exclusion
|
|
107
|
+
|
|
108
|
+
`/metrics` is excluded from its own collection and from request logging by default, so scraping doesn't inflate your metrics.
|
|
109
|
+
|
|
110
|
+
### Exports
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import {
|
|
114
|
+
metricsCollector, // middleware — collects request metrics
|
|
115
|
+
incrementCounter, // manually increment a counter
|
|
116
|
+
observeHistogram, // manually observe a histogram value
|
|
117
|
+
registerGaugeCallback, // register an async gauge callback
|
|
118
|
+
serializeMetrics, // produce Prometheus text format
|
|
119
|
+
resetMetrics, // clear all metrics (for tests)
|
|
120
|
+
closeMetricsQueues, // close cached BullMQ queue instances
|
|
121
|
+
} from "@lastshotlabs/bunshot";
|
|
122
|
+
|
|
123
|
+
import type {
|
|
124
|
+
MetricsConfig, // config shape for createApp/createServer
|
|
125
|
+
MetricsMiddlewareOptions, // options for metricsCollector()
|
|
126
|
+
} from "@lastshotlabs/bunshot";
|
|
127
|
+
```
|
|
@@ -1,189 +1,189 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
Pass `auth.oauth.providers` to `createServer` to enable Google, Apple, Microsoft, and/or GitHub sign-in. Routes are mounted automatically for each configured provider.
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
await createServer({
|
|
7
|
-
routesDir: import.meta.dir + "/routes",
|
|
8
|
-
app: { name: "My App", version: "1.0.0" },
|
|
9
|
-
auth: {
|
|
10
|
-
oauth: {
|
|
11
|
-
postRedirect: "/lobby", // where to redirect after login (default: "/")
|
|
12
|
-
providers: {
|
|
13
|
-
google: {
|
|
14
|
-
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
15
|
-
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
16
|
-
redirectUri: "https://myapp.com/auth/google/callback",
|
|
17
|
-
},
|
|
18
|
-
apple: {
|
|
19
|
-
clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.myapp.auth"
|
|
20
|
-
teamId: process.env.APPLE_TEAM_ID!,
|
|
21
|
-
keyId: process.env.APPLE_KEY_ID!,
|
|
22
|
-
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
|
|
23
|
-
redirectUri: "https://myapp.com/auth/apple/callback",
|
|
24
|
-
},
|
|
25
|
-
microsoft: {
|
|
26
|
-
tenantId: process.env.MICROSOFT_TENANT_ID!, // "common", "organizations", "consumers", or tenant GUID
|
|
27
|
-
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
|
28
|
-
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
|
29
|
-
redirectUri: "https://myapp.com/auth/microsoft/callback",
|
|
30
|
-
},
|
|
31
|
-
github: {
|
|
32
|
-
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
33
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
34
|
-
redirectUri: "https://myapp.com/auth/github/callback",
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
| Provider | Initiate login | Callback | Link to existing account | Unlink |
|
|
45
|
-
|---|---|---|---|---|
|
|
46
|
-
| Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
|
|
47
|
-
| Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
|
|
48
|
-
| Microsoft | `GET /auth/microsoft` | `GET /auth/microsoft/callback` | `GET /auth/microsoft/link` | `DELETE /auth/microsoft/link` |
|
|
49
|
-
| GitHub | `GET /auth/github` | `GET /auth/github/callback` | `GET /auth/github/link` | `DELETE /auth/github/link` |
|
|
50
|
-
|
|
51
|
-
> Apple sends its callback as a **POST** with form data. Your server must be publicly reachable and the redirect URI must be registered in the Apple developer console.
|
|
52
|
-
|
|
53
|
-
> **Microsoft `tenantId` options:** `"common"` accepts any Microsoft account (personal + work/school), `"organizations"` accepts work/school accounts only, `"consumers"` accepts personal accounts only, or pass a specific tenant GUID to restrict to a single Azure AD tenant (recommended for company SSO).
|
|
54
|
-
|
|
55
|
-
> **GitHub:** Create an OAuth App (not a GitHub App) at [github.com/settings/developers](https://github.com/settings/developers). The `user:email` scope is requested to retrieve the user's verified email address, since the primary `/user` endpoint may not return it for users with private email settings.
|
|
56
|
-
|
|
57
|
-
Additionally, a shared code exchange endpoint is always mounted:
|
|
58
|
-
|
|
59
|
-
| Endpoint | Purpose |
|
|
60
|
-
|---|---|
|
|
61
|
-
| `POST /auth/oauth/exchange` | Exchange one-time authorization code for session token |
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
1. Client navigates to `GET /auth/google` (or `/auth/apple`, `/auth/microsoft`, `/auth/github`)
|
|
66
|
-
2. Package redirects to the provider's OAuth page
|
|
67
|
-
3. Provider redirects (or POSTs) back to the callback URL
|
|
68
|
-
4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
|
|
69
|
-
5. A session is created and a **one-time authorization code** is generated
|
|
70
|
-
6. User is redirected to `auth.oauth.postRedirect?code=<one-time-code>`
|
|
71
|
-
7. Client exchanges the code for a session token via `POST /auth/oauth/exchange`
|
|
72
|
-
|
|
73
|
-
> **Security:** The JWT is never exposed in the redirect URL. The one-time code expires after 60 seconds and can only be used once, preventing token leakage via browser history, server logs, or referrer headers.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
After the OAuth redirect, the client must exchange the one-time code for a session token:
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
// Client-side
|
|
81
|
-
const res = await fetch("/auth/oauth/exchange", {
|
|
82
|
-
method: "POST",
|
|
83
|
-
headers: { "Content-Type": "application/json" },
|
|
84
|
-
body: JSON.stringify({ code: new URLSearchParams(location.search).get("code") }),
|
|
85
|
-
});
|
|
86
|
-
const { token, userId, email, refreshToken } = await res.json();
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
The exchange endpoint sets session cookies automatically for browser clients. Mobile/SPA clients can use the JSON response directly. Rate limited to 20 requests per minute per IP.
|
|
90
|
-
|
|
91
|
-
| Field | Description |
|
|
92
|
-
|---|---|
|
|
93
|
-
| `token` | Session JWT |
|
|
94
|
-
| `userId` | Authenticated user ID |
|
|
95
|
-
| `email` | User email (if available) |
|
|
96
|
-
| `refreshToken` | Refresh token (only when `auth.refreshTokens` is configured) |
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Pass `auth.oauth.allowedRedirectUrls` to restrict where OAuth callbacks can redirect:
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
auth: {
|
|
104
|
-
oauth: {
|
|
105
|
-
postRedirect: "/dashboard",
|
|
106
|
-
allowedRedirectUrls: ["https://myapp.com", "https://staging.myapp.com"],
|
|
107
|
-
providers: { ... },
|
|
108
|
-
},
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
When configured, the `postRedirect` value is validated against the allowlist at startup. If omitted, any redirect URL is accepted (not recommended for production).
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
The default `mongoAuthAdapter` stores social users in `AuthUser` with a `providerIds` field (e.g. `["google:1234567890"]`). If no existing provider key is found, a new account is created — emails are never auto-linked. To connect a social identity to an existing credential account the user must explicitly use the link flow below.
|
|
117
|
-
|
|
118
|
-
**Email conflict handling:** If a user attempts to sign in via Google (or Apple/Microsoft/GitHub) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
119
|
-
|
|
120
|
-
To support social login with a custom adapter, implement `findOrCreateByProvider`:
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
const myAdapter: AuthAdapter = {
|
|
124
|
-
findByEmail: ...,
|
|
125
|
-
create: ...,
|
|
126
|
-
async findOrCreateByProvider(provider, providerId, profile) {
|
|
127
|
-
// find or upsert user by provider + providerId
|
|
128
|
-
// return { id: string }
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
A logged-in user can link their account to a Google, Apple, Microsoft, or GitHub identity by navigating to the link route. This is the only way to associate a social login with an existing credential account — email matching is intentionally not done automatically.
|
|
136
|
-
|
|
137
|
-
```
|
|
138
|
-
GET /auth/google/link (requires active session via cookie)
|
|
139
|
-
GET /auth/apple/link (requires active session via cookie)
|
|
140
|
-
GET /auth/microsoft/link (requires active session via cookie)
|
|
141
|
-
GET /auth/github/link (requires active session via cookie)
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
The link flow:
|
|
145
|
-
1. User is already logged in (session cookie set)
|
|
146
|
-
2. Client navigates to `/auth/google/link`
|
|
147
|
-
3. User completes Google OAuth as normal
|
|
148
|
-
4. On callback, instead of creating a new session, the Google identity is added to their existing account
|
|
149
|
-
5. User is redirected to `auth.oauth.postRedirect?linked=google`
|
|
150
|
-
|
|
151
|
-
To support linking with a custom adapter, implement `linkProvider`:
|
|
152
|
-
|
|
153
|
-
```ts
|
|
154
|
-
const myAdapter: AuthAdapter = {
|
|
155
|
-
// ...
|
|
156
|
-
async linkProvider(userId, provider, providerId) {
|
|
157
|
-
const key = `${provider}:${providerId}`;
|
|
158
|
-
await db.update(users)
|
|
159
|
-
.set({ providerIds: sql`array_append(provider_ids, ${key})` })
|
|
160
|
-
.where(eq(users.id, userId));
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
A logged-in user can remove a linked Google, Microsoft, or GitHub identity via:
|
|
168
|
-
|
|
169
|
-
```
|
|
170
|
-
DELETE /auth/google/link (requires active session via cookie)
|
|
171
|
-
DELETE /auth/microsoft/link (requires active session via cookie)
|
|
172
|
-
DELETE /auth/github/link (requires active session via cookie)
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
|
|
176
|
-
|
|
177
|
-
To support unlinking with a custom adapter, implement `unlinkProvider`:
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
const myAdapter: AuthAdapter = {
|
|
181
|
-
// ...
|
|
182
|
-
async unlinkProvider(userId, provider) {
|
|
183
|
-
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
184
|
-
if (!user) throw new HttpError(404, "User not found");
|
|
185
|
-
const filtered = user.providerIds.filter((id: string) => !id.startsWith(`${provider}:`));
|
|
186
|
-
await db.update(users).set({ providerIds: filtered }).where(eq(users.id, userId));
|
|
187
|
-
},
|
|
188
|
-
};
|
|
189
|
-
```
|
|
1
|
+
### Social Login (OAuth)
|
|
2
|
+
|
|
3
|
+
Pass `auth.oauth.providers` to `createServer` to enable Google, Apple, Microsoft, and/or GitHub sign-in. Routes are mounted automatically for each configured provider.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
await createServer({
|
|
7
|
+
routesDir: import.meta.dir + "/routes",
|
|
8
|
+
app: { name: "My App", version: "1.0.0" },
|
|
9
|
+
auth: {
|
|
10
|
+
oauth: {
|
|
11
|
+
postRedirect: "/lobby", // where to redirect after login (default: "/")
|
|
12
|
+
providers: {
|
|
13
|
+
google: {
|
|
14
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
15
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
16
|
+
redirectUri: "https://myapp.com/auth/google/callback",
|
|
17
|
+
},
|
|
18
|
+
apple: {
|
|
19
|
+
clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.myapp.auth"
|
|
20
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
21
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
22
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
|
|
23
|
+
redirectUri: "https://myapp.com/auth/apple/callback",
|
|
24
|
+
},
|
|
25
|
+
microsoft: {
|
|
26
|
+
tenantId: process.env.MICROSOFT_TENANT_ID!, // "common", "organizations", "consumers", or tenant GUID
|
|
27
|
+
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
|
28
|
+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
|
29
|
+
redirectUri: "https://myapp.com/auth/microsoft/callback",
|
|
30
|
+
},
|
|
31
|
+
github: {
|
|
32
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
33
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
34
|
+
redirectUri: "https://myapp.com/auth/github/callback",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Routes mounted automatically
|
|
43
|
+
|
|
44
|
+
| Provider | Initiate login | Callback | Link to existing account | Unlink |
|
|
45
|
+
|---|---|---|---|---|
|
|
46
|
+
| Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
|
|
47
|
+
| Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
|
|
48
|
+
| Microsoft | `GET /auth/microsoft` | `GET /auth/microsoft/callback` | `GET /auth/microsoft/link` | `DELETE /auth/microsoft/link` |
|
|
49
|
+
| GitHub | `GET /auth/github` | `GET /auth/github/callback` | `GET /auth/github/link` | `DELETE /auth/github/link` |
|
|
50
|
+
|
|
51
|
+
> Apple sends its callback as a **POST** with form data. Your server must be publicly reachable and the redirect URI must be registered in the Apple developer console.
|
|
52
|
+
|
|
53
|
+
> **Microsoft `tenantId` options:** `"common"` accepts any Microsoft account (personal + work/school), `"organizations"` accepts work/school accounts only, `"consumers"` accepts personal accounts only, or pass a specific tenant GUID to restrict to a single Azure AD tenant (recommended for company SSO).
|
|
54
|
+
|
|
55
|
+
> **GitHub:** Create an OAuth App (not a GitHub App) at [github.com/settings/developers](https://github.com/settings/developers). The `user:email` scope is requested to retrieve the user's verified email address, since the primary `/user` endpoint may not return it for users with private email settings.
|
|
56
|
+
|
|
57
|
+
Additionally, a shared code exchange endpoint is always mounted:
|
|
58
|
+
|
|
59
|
+
| Endpoint | Purpose |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `POST /auth/oauth/exchange` | Exchange one-time authorization code for session token |
|
|
62
|
+
|
|
63
|
+
#### Flow
|
|
64
|
+
|
|
65
|
+
1. Client navigates to `GET /auth/google` (or `/auth/apple`, `/auth/microsoft`, `/auth/github`)
|
|
66
|
+
2. Package redirects to the provider's OAuth page
|
|
67
|
+
3. Provider redirects (or POSTs) back to the callback URL
|
|
68
|
+
4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
|
|
69
|
+
5. A session is created and a **one-time authorization code** is generated
|
|
70
|
+
6. User is redirected to `auth.oauth.postRedirect?code=<one-time-code>`
|
|
71
|
+
7. Client exchanges the code for a session token via `POST /auth/oauth/exchange`
|
|
72
|
+
|
|
73
|
+
> **Security:** The JWT is never exposed in the redirect URL. The one-time code expires after 60 seconds and can only be used once, preventing token leakage via browser history, server logs, or referrer headers.
|
|
74
|
+
|
|
75
|
+
##### Code exchange
|
|
76
|
+
|
|
77
|
+
After the OAuth redirect, the client must exchange the one-time code for a session token:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// Client-side
|
|
81
|
+
const res = await fetch("/auth/oauth/exchange", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ code: new URLSearchParams(location.search).get("code") }),
|
|
85
|
+
});
|
|
86
|
+
const { token, userId, email, refreshToken } = await res.json();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The exchange endpoint sets session cookies automatically for browser clients. Mobile/SPA clients can use the JSON response directly. Rate limited to 20 requests per minute per IP.
|
|
90
|
+
|
|
91
|
+
| Field | Description |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `token` | Session JWT |
|
|
94
|
+
| `userId` | Authenticated user ID |
|
|
95
|
+
| `email` | User email (if available) |
|
|
96
|
+
| `refreshToken` | Refresh token (only when `auth.refreshTokens` is configured) |
|
|
97
|
+
|
|
98
|
+
#### Redirect URL validation
|
|
99
|
+
|
|
100
|
+
Pass `auth.oauth.allowedRedirectUrls` to restrict where OAuth callbacks can redirect:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
auth: {
|
|
104
|
+
oauth: {
|
|
105
|
+
postRedirect: "/dashboard",
|
|
106
|
+
allowedRedirectUrls: ["https://myapp.com", "https://staging.myapp.com"],
|
|
107
|
+
providers: { ... },
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When configured, the `postRedirect` value is validated against the allowlist at startup. If omitted, any redirect URL is accepted (not recommended for production).
|
|
113
|
+
|
|
114
|
+
#### User storage
|
|
115
|
+
|
|
116
|
+
The default `mongoAuthAdapter` stores social users in `AuthUser` with a `providerIds` field (e.g. `["google:1234567890"]`). If no existing provider key is found, a new account is created — emails are never auto-linked. To connect a social identity to an existing credential account the user must explicitly use the link flow below.
|
|
117
|
+
|
|
118
|
+
**Email conflict handling:** If a user attempts to sign in via Google (or Apple/Microsoft/GitHub) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
119
|
+
|
|
120
|
+
To support social login with a custom adapter, implement `findOrCreateByProvider`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const myAdapter: AuthAdapter = {
|
|
124
|
+
findByEmail: ...,
|
|
125
|
+
create: ...,
|
|
126
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
127
|
+
// find or upsert user by provider + providerId
|
|
128
|
+
// return { id: string }
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### Linking a provider to an existing account
|
|
134
|
+
|
|
135
|
+
A logged-in user can link their account to a Google, Apple, Microsoft, or GitHub identity by navigating to the link route. This is the only way to associate a social login with an existing credential account — email matching is intentionally not done automatically.
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
GET /auth/google/link (requires active session via cookie)
|
|
139
|
+
GET /auth/apple/link (requires active session via cookie)
|
|
140
|
+
GET /auth/microsoft/link (requires active session via cookie)
|
|
141
|
+
GET /auth/github/link (requires active session via cookie)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The link flow:
|
|
145
|
+
1. User is already logged in (session cookie set)
|
|
146
|
+
2. Client navigates to `/auth/google/link`
|
|
147
|
+
3. User completes Google OAuth as normal
|
|
148
|
+
4. On callback, instead of creating a new session, the Google identity is added to their existing account
|
|
149
|
+
5. User is redirected to `auth.oauth.postRedirect?linked=google`
|
|
150
|
+
|
|
151
|
+
To support linking with a custom adapter, implement `linkProvider`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const myAdapter: AuthAdapter = {
|
|
155
|
+
// ...
|
|
156
|
+
async linkProvider(userId, provider, providerId) {
|
|
157
|
+
const key = `${provider}:${providerId}`;
|
|
158
|
+
await db.update(users)
|
|
159
|
+
.set({ providerIds: sql`array_append(provider_ids, ${key})` })
|
|
160
|
+
.where(eq(users.id, userId));
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### Unlinking a provider
|
|
166
|
+
|
|
167
|
+
A logged-in user can remove a linked Google, Microsoft, or GitHub identity via:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
DELETE /auth/google/link (requires active session via cookie)
|
|
171
|
+
DELETE /auth/microsoft/link (requires active session via cookie)
|
|
172
|
+
DELETE /auth/github/link (requires active session via cookie)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
|
|
176
|
+
|
|
177
|
+
To support unlinking with a custom adapter, implement `unlinkProvider`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const myAdapter: AuthAdapter = {
|
|
181
|
+
// ...
|
|
182
|
+
async unlinkProvider(userId, provider) {
|
|
183
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
184
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
185
|
+
const filtered = user.providerIds.filter((id: string) => !id.startsWith(`${provider}:`));
|
|
186
|
+
await db.update(users).set({ providerIds: filtered }).where(eq(users.id, userId));
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
```
|