@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/social.md
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Social Login
|
|
3
|
+
description: Authenticate users through GitHub, Google, and Apple with OAuth2 drivers that handle CSRF state, token exchange, and profile normalisation for you.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Social Login
|
|
7
|
+
|
|
8
|
+
Social login ships as part of `@zerotal/auth` — OAuth2-based authentication with
|
|
9
|
+
built-in drivers for GitHub, Google, and Apple. CSRF state management, GET/POST
|
|
10
|
+
normalisation, GitHub's hidden-email fallback, and Apple's JWT signing are all
|
|
11
|
+
handled inside the driver — your controller stays thin.
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
Social login is included in `@zerotal/auth`; if you already have the auth
|
|
16
|
+
package installed there is nothing extra to add. Otherwise:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# in your project root
|
|
20
|
+
bun add @zerotal/auth
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Register the provider
|
|
24
|
+
|
|
25
|
+
Add `SocialProvider` to the providers array in `bootstrap/providers.ts`:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// bootstrap/providers.ts
|
|
29
|
+
import { SocialProvider } from "@zerotal/auth";
|
|
30
|
+
|
|
31
|
+
const providers = [
|
|
32
|
+
// …your other providers
|
|
33
|
+
SocialProvider,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default providers;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Registering the provider switches on the following (in lifecycle order):
|
|
40
|
+
|
|
41
|
+
- `onRegister` — binds the `SocialManager` as the `"social"` singleton and
|
|
42
|
+
registers the `Router.social()` router macro, so the macro is available in
|
|
43
|
+
every route file before the app boots.
|
|
44
|
+
- `onBooted` — reads `config/social.ts` from the container's `config` service
|
|
45
|
+
and instantiates a built-in driver (`github`, `google`, `apple`) for every key
|
|
46
|
+
it recognises. Unrecognised keys are silently skipped.
|
|
47
|
+
|
|
48
|
+
> **Note** — `SocialProvider` only runs in the `web` and `test` environments
|
|
49
|
+
> (`static environments = ["web", "test"]`). If the `config` service is
|
|
50
|
+
> unavailable at boot, no drivers are auto-registered — register them yourself
|
|
51
|
+
> with `Social.register()` (see [Writing a custom driver](#writing-a-custom-driver)).
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Create `config/social.ts` using the `SocialConfig()` helper (or `satisfies
|
|
56
|
+
SocialConfigShape`). The config maps each provider name to its OAuth2
|
|
57
|
+
credentials — there are no framework defaults, so include only the providers you
|
|
58
|
+
use and source secrets from the environment with `env()`:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// config/social.ts
|
|
62
|
+
import { SocialConfig } from "@zerotal/auth";
|
|
63
|
+
import { env } from "zerotal";
|
|
64
|
+
|
|
65
|
+
export default SocialConfig({
|
|
66
|
+
github: {
|
|
67
|
+
clientId: env("GITHUB_CLIENT_ID", ""),
|
|
68
|
+
clientSecret: env("GITHUB_CLIENT_SECRET", ""),
|
|
69
|
+
redirectUrl: env("GITHUB_REDIRECT_URL", ""), // https://myapp.com/auth/github/callback
|
|
70
|
+
},
|
|
71
|
+
google: {
|
|
72
|
+
clientId: env("GOOGLE_CLIENT_ID", ""),
|
|
73
|
+
clientSecret: env("GOOGLE_CLIENT_SECRET", ""),
|
|
74
|
+
redirectUrl: env("GOOGLE_REDIRECT_URL", ""),
|
|
75
|
+
},
|
|
76
|
+
// apple: { … } ← see the Apple section below
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Each provider entry accepts the following fields:
|
|
81
|
+
|
|
82
|
+
| Field | Required | Default | Description |
|
|
83
|
+
| -------------- | -------- | ------------------- | ------------------------------------------------------------------- |
|
|
84
|
+
| `clientId` | yes | — | OAuth2 client ID (Apple Service ID). |
|
|
85
|
+
| `clientSecret` | no\* | — | Static client secret. Apple may omit it and supply raw credentials. |
|
|
86
|
+
| `redirectUrl` | yes | — | The callback URL registered with the provider. |
|
|
87
|
+
| `scopes` | no | per-driver defaults | Override the driver's default scopes (see each provider section). |
|
|
88
|
+
|
|
89
|
+
\* For Apple you supply either `clientSecret` (a pre-signed JWT) or the raw
|
|
90
|
+
`teamId` / `keyId` / `privateKey` trio — see [Apple](#apple).
|
|
91
|
+
|
|
92
|
+
## Login flow
|
|
93
|
+
|
|
94
|
+
### 1. Write a controller
|
|
95
|
+
|
|
96
|
+
The driver handles CSRF state, session storage, and code extraction. Your
|
|
97
|
+
controller is a few lines per action — read the provider from `ctx.params`:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// app/controllers/SocialController.ts
|
|
101
|
+
import { Social } from "@zerotal/auth";
|
|
102
|
+
import type { HttpContext } from "zerotal";
|
|
103
|
+
import { User } from "../models/User.ts";
|
|
104
|
+
|
|
105
|
+
export class SocialController {
|
|
106
|
+
// Step 1 — generate state, store in session, redirect to provider
|
|
107
|
+
async redirect(ctx: HttpContext) {
|
|
108
|
+
return Social.driver(ctx.params.provider).redirect();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Step 2 — verify state, extract code, fetch user profile
|
|
112
|
+
async callback(ctx: HttpContext) {
|
|
113
|
+
try {
|
|
114
|
+
const socialUser = await Social.driver(ctx.params.provider).user();
|
|
115
|
+
|
|
116
|
+
// Find or create the local user — that's all you need to write
|
|
117
|
+
const user = await User.firstOrCreate(
|
|
118
|
+
{ provider_id: socialUser.id },
|
|
119
|
+
{
|
|
120
|
+
name: socialUser.name,
|
|
121
|
+
email: socialUser.email,
|
|
122
|
+
provider: ctx.params.provider,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
ctx.session.regenerate();
|
|
127
|
+
ctx.session.set("user_id", user.id);
|
|
128
|
+
ctx.redirect("/dashboard");
|
|
129
|
+
} catch (e: unknown) {
|
|
130
|
+
const msg = e instanceof Error ? e.message : "unknown_error";
|
|
131
|
+
ctx.redirect(`/login?error=${msg}`);
|
|
132
|
+
// e.message is 'invalid_state' or 'missing_code' on validation failure,
|
|
133
|
+
// or a provider error message on token/profile fetch failure.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
> **Note** — On a stateful (session) flow, `.user()` is called with no arguments;
|
|
140
|
+
> the driver reads the active request from async-local storage, extracts `code`
|
|
141
|
+
> and `state`, and verifies `state` against the session before exchanging the
|
|
142
|
+
> code.
|
|
143
|
+
|
|
144
|
+
### 2. Register routes
|
|
145
|
+
|
|
146
|
+
`SocialProvider` registers `Router.social()` as a router macro — the same
|
|
147
|
+
mechanism used by `@zerotal/flow` (`Router.flow`) and `@zerotal/inertia`
|
|
148
|
+
(`Router.inertia`). Once `SocialProvider` is in your bootstrap, the macro is
|
|
149
|
+
available in every route file with no additional import:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// routes/auth.ts
|
|
153
|
+
import { Router } from "zerotal";
|
|
154
|
+
import { SocialController } from "../app/controllers/SocialController.ts";
|
|
155
|
+
|
|
156
|
+
Router.social("/auth", SocialController);
|
|
157
|
+
// Expands to:
|
|
158
|
+
// Router.get('/auth/:provider', SocialController, 'redirect');
|
|
159
|
+
// Router.get('/auth/:provider/callback', SocialController, 'callback');
|
|
160
|
+
// Router.post('/auth/:provider/callback', SocialController, 'callback'); // Apple
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Or register routes individually for full control:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// routes/auth.ts
|
|
167
|
+
import { Router } from "zerotal";
|
|
168
|
+
import { SocialController } from "../app/controllers/SocialController.ts";
|
|
169
|
+
|
|
170
|
+
Router.get("/auth/:provider", SocialController, "redirect");
|
|
171
|
+
Router.get("/auth/:provider/callback", SocialController, "callback");
|
|
172
|
+
Router.post("/auth/:provider/callback", SocialController, "callback");
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### The SocialUser shape
|
|
176
|
+
|
|
177
|
+
Every driver normalises the raw provider response into the same interface:
|
|
178
|
+
|
|
179
|
+
| Property | Type | Description |
|
|
180
|
+
| -------------- | ------------------------- | ------------------------------------------------------- |
|
|
181
|
+
| `id` | `string` | Provider-unique user ID. |
|
|
182
|
+
| `name` | `string` | Display name. |
|
|
183
|
+
| `email` | `string \| null` | Primary email (always resolved — see per-driver notes). |
|
|
184
|
+
| `avatar` | `string \| null` | Profile picture URL. |
|
|
185
|
+
| `token` | `string` | Raw access token. |
|
|
186
|
+
| `refreshToken` | `string \| null` | Refresh token, when the provider issues one. |
|
|
187
|
+
| `expiresIn` | `number \| null` | Access-token lifetime in seconds, when provided. |
|
|
188
|
+
| `raw` | `Record<string, unknown>` | Full provider payload for custom fields. |
|
|
189
|
+
|
|
190
|
+
Most providers only return a `refreshToken` when you explicitly request offline
|
|
191
|
+
access — see [Requesting scopes and parameters](#requesting-scopes-and-parameters)
|
|
192
|
+
for Google's `access_type=offline` example. Store the refresh token if you need to
|
|
193
|
+
call the provider's API on the user's behalf later.
|
|
194
|
+
|
|
195
|
+
## GitHub
|
|
196
|
+
|
|
197
|
+
Default scopes: `read:user user:email`.
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// config/social.ts
|
|
201
|
+
github: {
|
|
202
|
+
clientId: env("GITHUB_CLIENT_ID", ""),
|
|
203
|
+
clientSecret: env("GITHUB_CLIENT_SECRET", ""),
|
|
204
|
+
redirectUrl: env("GITHUB_REDIRECT_URL", ""),
|
|
205
|
+
scopes: ['read:user', 'user:email', 'repo'], // override only if you need extra scopes
|
|
206
|
+
},
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
GitHub returns `null` for `email` when the user has a private address.
|
|
210
|
+
`GitHubDriver` calls `/user/emails` internally and populates the verified
|
|
211
|
+
primary email before returning `SocialUser` — no extra code in your controller.
|
|
212
|
+
|
|
213
|
+
> **Note** — `GitHubDriver` omits `response_type=code` from the authorization URL;
|
|
214
|
+
> GitHub returns a 404 if it is present. Custom GitHub-style drivers can do the
|
|
215
|
+
> same by overriding `includeResponseType()` to return `false`.
|
|
216
|
+
|
|
217
|
+
## Google
|
|
218
|
+
|
|
219
|
+
Default scopes: `openid profile email`.
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// config/social.ts
|
|
223
|
+
google: {
|
|
224
|
+
clientId: env("GOOGLE_CLIENT_ID", ""),
|
|
225
|
+
clientSecret: env("GOOGLE_CLIENT_SECRET", ""),
|
|
226
|
+
redirectUrl: env("GOOGLE_REDIRECT_URL", ""),
|
|
227
|
+
},
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Google always returns a verified email. `socialUser.email` is never null with
|
|
231
|
+
the default scopes.
|
|
232
|
+
|
|
233
|
+
## Apple
|
|
234
|
+
|
|
235
|
+
Sign in with Apple has three quirks — all handled internally by `AppleDriver`:
|
|
236
|
+
|
|
237
|
+
1. **User profile is sent only once.** Apple sends `name` and `email` on the very
|
|
238
|
+
first authorization. On every subsequent login those fields are absent. Store
|
|
239
|
+
them in your database on the first callback.
|
|
240
|
+
2. **Callbacks arrive as POST requests.** Apple uses `response_mode: form_post`.
|
|
241
|
+
The driver reads `code` and `state` from the POST body automatically — your
|
|
242
|
+
controller code is identical to other providers.
|
|
243
|
+
3. **`clientSecret` is a signed ES256 JWT.** You have two options below.
|
|
244
|
+
|
|
245
|
+
> **Warning** — Because Apple sends the profile only on the first login, you must
|
|
246
|
+
> persist `socialUser.name` and `socialUser.email` on the first callback. Later
|
|
247
|
+
> logins will have `name` empty and `email` populated only from the `id_token`.
|
|
248
|
+
|
|
249
|
+
**Option A — supply raw credentials (recommended).** Pass your Apple Developer
|
|
250
|
+
credentials and the driver signs the JWT automatically using the Web Crypto API.
|
|
251
|
+
No extra dependency needed:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
// config/social.ts
|
|
255
|
+
apple: {
|
|
256
|
+
clientId: 'com.myapp.service', // your Service ID
|
|
257
|
+
teamId: env("APPLE_TEAM_ID", ""), // 10-character Team ID
|
|
258
|
+
keyId: env("APPLE_KEY_ID", ""), // Key ID from App Store Connect
|
|
259
|
+
privateKey: env("APPLE_PRIVATE_KEY", ""), // full PEM string (-----BEGIN PRIVATE KEY-----)
|
|
260
|
+
redirectUrl: env("APPLE_REDIRECT_URL", ""),
|
|
261
|
+
},
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Option B — pre-sign the JWT yourself** (e.g. with `apple-signin-auth`) and pass
|
|
265
|
+
it as `clientSecret`. Useful if you rotate the JWT externally:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// config/social.ts
|
|
269
|
+
apple: {
|
|
270
|
+
clientId: 'com.myapp.service',
|
|
271
|
+
clientSecret: generateAppleClientSecret(), // your pre-signed JWT
|
|
272
|
+
redirectUrl: env("APPLE_REDIRECT_URL", ""),
|
|
273
|
+
},
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
> **Note** — If you supply neither a `clientSecret` nor the `teamId` / `keyId` /
|
|
277
|
+
> `privateKey` trio, `AppleDriver` throws `AppleClientSecretError` on the first
|
|
278
|
+
> token exchange.
|
|
279
|
+
|
|
280
|
+
## More built-in providers
|
|
281
|
+
|
|
282
|
+
Alongside GitHub, Google, and Apple, six more drivers ship built-in and are
|
|
283
|
+
auto-registered from `config/social.ts` by their key — just supply credentials:
|
|
284
|
+
|
|
285
|
+
| Key | Driver | Default scopes | Notes |
|
|
286
|
+
| ----------- | ----------------- | -------------------------------- | -------------------------------------------------------------------- |
|
|
287
|
+
| `discord` | `DiscordDriver` | `identify email` | Avatar URL is built from the user id + avatar hash. |
|
|
288
|
+
| `microsoft` | `MicrosoftDriver` | `openid profile email User.Read` | Uses the common tenant + Microsoft Graph `/me`. |
|
|
289
|
+
| `facebook` | `FacebookDriver` | `email public_profile` | Requests `picture.type(large)` for the avatar. |
|
|
290
|
+
| `twitter` | `TwitterDriver` | `tweet.read users.read` | OAuth2; the profile is under `data`, and email is `null` by default. |
|
|
291
|
+
| `linkedin` | `LinkedInDriver` | `openid profile email` | OpenID Connect userinfo (`sub` is the id). |
|
|
292
|
+
| `gitlab` | `GitLabDriver` | `read_user` | Targets gitlab.com. |
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// config/social.ts — same shape as github/google/apple
|
|
296
|
+
discord: {
|
|
297
|
+
clientId: env("DISCORD_CLIENT_ID", ""),
|
|
298
|
+
clientSecret: env("DISCORD_CLIENT_SECRET", ""),
|
|
299
|
+
redirectUrl: env("DISCORD_REDIRECT_URL", ""),
|
|
300
|
+
},
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Each is also exported (`DiscordDriver`, `MicrosoftDriver`, …) for manual
|
|
304
|
+
registration via `Social.register()`, and you can still
|
|
305
|
+
[write a custom driver](#writing-a-custom-driver) for anything not covered.
|
|
306
|
+
|
|
307
|
+
## Requesting scopes and parameters
|
|
308
|
+
|
|
309
|
+
Scopes can be set per-provider in `config/social.ts`, but you can also add or
|
|
310
|
+
replace them fluently at redirect time. `.scopes()` merges with the configured /
|
|
311
|
+
default scopes; `.setScopes()` replaces them outright:
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
// Ask for extra GitHub scopes on top of the defaults
|
|
315
|
+
Social.driver("github").scopes(["repo", "read:org"]).redirect();
|
|
316
|
+
|
|
317
|
+
// Replace the scope list entirely
|
|
318
|
+
Social.driver("github").setScopes(["read:user"]).redirect();
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Use `.with()` to append provider-specific query parameters to the authorization
|
|
322
|
+
URL. This is how you request a refresh token from Google — Google only returns one
|
|
323
|
+
when you ask for offline access and force the consent screen:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
Social.driver("google").with({ access_type: "offline", prompt: "consent" }).redirect();
|
|
327
|
+
|
|
328
|
+
// On callback, socialUser.refreshToken is now populated.
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Each fluent call returns a fresh copy of the driver, so the singleton registered
|
|
332
|
+
by `SocialProvider` is never mutated and concurrent requests don't interfere.
|
|
333
|
+
|
|
334
|
+
## Retrieving a user from a token
|
|
335
|
+
|
|
336
|
+
If your client already holds an access token — for example a mobile app that ran
|
|
337
|
+
its own native OAuth SDK — skip the code exchange and fetch the profile directly
|
|
338
|
+
with `userFromToken()`:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
const socialUser = await Social.driver("github").userFromToken(accessToken);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The returned user has no `refreshToken` or `expiresIn` (those only come from a
|
|
345
|
+
code exchange), but `id`, `name`, `email`, and `avatar` are fully resolved.
|
|
346
|
+
|
|
347
|
+
## Stateless mode
|
|
348
|
+
|
|
349
|
+
When you're building an API backend for an SPA or mobile app, you may receive a
|
|
350
|
+
raw `code` from the client without a session. Call `.stateless()` to skip CSRF
|
|
351
|
+
state verification and pass the code directly to `.user()`:
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// app/controllers/SocialApiController.ts — POST /auth/callback { provider, code }
|
|
355
|
+
import { Social } from "@zerotal/auth";
|
|
356
|
+
import type { HttpContext } from "zerotal";
|
|
357
|
+
|
|
358
|
+
async callback(ctx: HttpContext) {
|
|
359
|
+
const { provider, code } = await ctx.request.json<{ provider: string; code: string }>();
|
|
360
|
+
const socialUser = await Social.driver(provider).stateless().user(code);
|
|
361
|
+
// … find or create user, return JWT …
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`.stateless()` returns a shallow copy of the driver — the singleton registered by
|
|
366
|
+
`SocialProvider` is never mutated.
|
|
367
|
+
|
|
368
|
+
### Stateful or stateless — which should I use?
|
|
369
|
+
|
|
370
|
+
- **Stateful (the default).** Server-rendered apps where the browser holds a
|
|
371
|
+
session cookie. Call `.redirect()` then `.user()` with no arguments; the driver
|
|
372
|
+
generates and verifies the CSRF `state` for you.
|
|
373
|
+
- **Stateless.** API backends for an SPA or native app that send the raw `code`
|
|
374
|
+
themselves and have no server session. Call `.stateless().user(code)`; CSRF
|
|
375
|
+
state verification is skipped, so verify the request another way (e.g. PKCE on
|
|
376
|
+
the client).
|
|
377
|
+
|
|
378
|
+
> **Warning** — Stateless mode disables CSRF `state` verification. Only use it
|
|
379
|
+
> when the client performs its own request integrity check; otherwise prefer the
|
|
380
|
+
> stateful flow.
|
|
381
|
+
|
|
382
|
+
## Testing
|
|
383
|
+
|
|
384
|
+
`Social.fake()` swaps a provider for a stub driver that never touches the network.
|
|
385
|
+
`redirect()` still issues a real redirect (so redirect-route tests pass), while
|
|
386
|
+
`user()` returns a canned profile. Build that profile with `fakeSocialUser()`,
|
|
387
|
+
overriding only the fields your test cares about:
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
import { Social, fakeSocialUser } from "@zerotal/auth";
|
|
391
|
+
|
|
392
|
+
test("logs a user in via GitHub", async () => {
|
|
393
|
+
Social.fake("github", fakeSocialUser({ id: "github-123", email: "jane@example.com" }));
|
|
394
|
+
|
|
395
|
+
const res = await app.get("/auth/github/callback");
|
|
396
|
+
|
|
397
|
+
res.assertRedirect("/dashboard");
|
|
398
|
+
await assertDatabaseHas("users", { email: "jane@example.com", github_id: "github-123" });
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`fakeSocialUser()` fills complete, valid defaults (including `token`,
|
|
403
|
+
`refreshToken`, and `expiresIn`), so you only specify what matters to the
|
|
404
|
+
assertion.
|
|
405
|
+
|
|
406
|
+
## Writing a custom driver
|
|
407
|
+
|
|
408
|
+
Extend `OAuth2Driver` and implement the five abstract members — `authUrl()`,
|
|
409
|
+
`tokenUrl()`, `userUrl()`, `defaultScopes()`, and `normalise()`:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
// app/social/TwitterDriver.ts
|
|
413
|
+
import { OAuth2Driver } from "@zerotal/auth";
|
|
414
|
+
import type { SocialUser } from "@zerotal/auth";
|
|
415
|
+
|
|
416
|
+
export class TwitterDriver extends OAuth2Driver {
|
|
417
|
+
protected authUrl() {
|
|
418
|
+
return "https://twitter.com/i/oauth2/authorize";
|
|
419
|
+
}
|
|
420
|
+
protected tokenUrl() {
|
|
421
|
+
return "https://api.twitter.com/2/oauth2/token";
|
|
422
|
+
}
|
|
423
|
+
protected userUrl() {
|
|
424
|
+
return "https://api.twitter.com/2/users/me?user.fields=profile_image_url";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
protected defaultScopes() {
|
|
428
|
+
return ["tweet.read", "users.read"];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
protected normalise(raw: Record<string, unknown>, token: string): SocialUser {
|
|
432
|
+
const data = raw["data"] as Record<string, unknown>;
|
|
433
|
+
return {
|
|
434
|
+
id: String(data["id"]),
|
|
435
|
+
name: String(data["name"] ?? ""),
|
|
436
|
+
email: null, // requires extra scope
|
|
437
|
+
avatar: typeof data["profile_image_url"] === "string" ? data["profile_image_url"] : null,
|
|
438
|
+
token,
|
|
439
|
+
raw,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Register it via the `Social` facade — typically in an `AppProvider`'s `onBooted`
|
|
446
|
+
hook, since the `"social"` singleton is bound during `onRegister`:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// app/providers/AppProvider.ts
|
|
450
|
+
import { ServiceProvider } from "zerotal";
|
|
451
|
+
import { Social } from "@zerotal/auth";
|
|
452
|
+
import { TwitterDriver } from "../social/TwitterDriver.ts";
|
|
453
|
+
|
|
454
|
+
export class AppProvider extends ServiceProvider {
|
|
455
|
+
override async onBooted(): Promise<void> {
|
|
456
|
+
Social.register(
|
|
457
|
+
"twitter",
|
|
458
|
+
new TwitterDriver({
|
|
459
|
+
clientId: Bun.env.TWITTER_CLIENT_ID!,
|
|
460
|
+
clientSecret: Bun.env.TWITTER_CLIENT_SECRET!,
|
|
461
|
+
redirectUrl: Bun.env.TWITTER_REDIRECT_URL!,
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`Social.register()` adds the driver to the manager directly, so a custom driver
|
|
469
|
+
does not need a `config/social.ts` entry (built-in keys are the only ones
|
|
470
|
+
auto-registered by `SocialProvider`).
|
|
471
|
+
|
|
472
|
+
> **Tip** — Override `afterNormalise()` to enrich the profile with a second
|
|
473
|
+
> request (this is how `GitHubDriver` resolves hidden emails), `extraAuthParams()`
|
|
474
|
+
> to append query params to the redirect, or `_extractCodeAndState()` to read the
|
|
475
|
+
> callback from somewhere other than the query string (this is how `AppleDriver`
|
|
476
|
+
> handles `form_post`).
|
|
477
|
+
|
|
478
|
+
## References
|
|
479
|
+
|
|
480
|
+
### `Social` facade
|
|
481
|
+
|
|
482
|
+
Resolves the `"social"` singleton (`SocialManager`).
|
|
483
|
+
|
|
484
|
+
| Method | Signature | Description |
|
|
485
|
+
| ---------- | ---------------------------------------------------- | ------------------------------------------------ |
|
|
486
|
+
| `driver` | `driver(name: string): OAuth2Driver` | Get a registered driver. Throws if not found. |
|
|
487
|
+
| `register` | `register(name: string, driver: OAuth2Driver): this` | Register a driver under a name. |
|
|
488
|
+
| `drivers` | `drivers(): string[]` | List all registered driver names. |
|
|
489
|
+
| `fake` | `fake(name: string, user?: SocialUser): SocialUser` | Swap a provider for a no-network stub (testing). |
|
|
490
|
+
|
|
491
|
+
### `OAuth2Driver` (public methods)
|
|
492
|
+
|
|
493
|
+
| Method | Signature | Description |
|
|
494
|
+
| --------------- | --------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
495
|
+
| `redirect` | `redirect(): void` | Generate CSRF state, store it in the session, redirect to the provider. |
|
|
496
|
+
| `user` | `user(code?: string): Promise<SocialUser>` | Exchange the callback for a profile. With `code`, runs stateless. |
|
|
497
|
+
| `userFromToken` | `userFromToken(token: string): Promise<SocialUser>` | Fetch a profile from an access token you already hold. |
|
|
498
|
+
| `scopes` | `scopes(scopes: string[]): this` | Add scopes (merged with defaults). Returns a copy. |
|
|
499
|
+
| `setScopes` | `setScopes(scopes: string[]): this` | Replace all scopes. Returns a copy. |
|
|
500
|
+
| `with` | `with(params: Record<string, string>): this` | Append optional auth-URL params (e.g. `access_type`). Returns a copy. |
|
|
501
|
+
| `stateless` | `stateless(): this` | Return a copy of the driver with CSRF state verification disabled. |
|
|
502
|
+
| `redirectUrl` | `redirectUrl(state: string): string` | Build the raw authorization URL (low-level / testing). |
|
|
503
|
+
|
|
504
|
+
### Errors
|
|
505
|
+
|
|
506
|
+
All extend `SocialError` (which extends `ZerotalError`). The `message` of the two
|
|
507
|
+
validation errors is exactly the string you forward to `?error=`.
|
|
508
|
+
|
|
509
|
+
| Error | `message` | When |
|
|
510
|
+
| ------------------------------- | ------------------------- | -------------------------------------------------- |
|
|
511
|
+
| `OAuthStateMismatchError` | `invalid_state` | Callback `state` missing or doesn't match session. |
|
|
512
|
+
| `OAuthMissingCodeError` | `missing_code` | Callback has no authorization `code`. |
|
|
513
|
+
| `UnknownSocialDriverError` | driver-name message | `Social.driver(name)` with an unregistered name. |
|
|
514
|
+
| `SocialContextUnavailableError` | context message | Stateful flow run outside an HTTP request. |
|
|
515
|
+
| `OAuthTokenExchangeError` | provider message | Token exchange failed or returned no token. |
|
|
516
|
+
| `OAuthUserFetchError` | provider message | User-profile fetch failed. |
|
|
517
|
+
| `AppleClientSecretError` | Apple credentials message | Apple driver has neither a JWT nor raw key trio. |
|
|
518
|
+
|
|
519
|
+
## Next steps
|
|
520
|
+
|
|
521
|
+
- [Authentication](/docs/authentication) — establish the session after a social login.
|
|
522
|
+
- [Authorization](/docs/authorization) — gate routes once the user is signed in.
|
|
523
|
+
- [CSRF](/docs/csrf) — understand the state protection the drivers apply.
|
|
524
|
+
- [Session](/docs/session) — manage the logged-in user across requests.
|