@appweaver/create-weaver-app 1.2.1 → 1.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appweaver/create-weaver-app",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Appweaver - the backend framework for AI-first development (@create-weaver-app)",
5
5
  "author": "Luka Matosevic",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "engines": {
28
- "node": ">= 20"
28
+ "node": ">= 22"
29
29
  },
30
30
  "scripts": {
31
31
  "create-weaver-app": "node ./dist/create-weaver-app.js"
package/skill/SKILL.md CHANGED
@@ -35,7 +35,7 @@ The basic file structure of the Appweaver project:
35
35
  - `test/e2e/` - the end-to-end tests root directory
36
36
  - `test/unit/` - the unit tests root directory
37
37
  - `.env` - override the central configuration (optional)
38
- - `.env.{env}` - override the central configuration for specific envirnment (optional)
38
+ - `.env.{env}` - override the central configuration for a specific environment (optional)
39
39
  - `appweaver.json` - central library configuration file
40
40
  - `appweaver.{env}.json` - environment specific configuration files that override the central configuration
41
41
  - `Dockerfile` - the dockerfile used for building a docker image for deploying the application
@@ -414,8 +414,8 @@ await injectService('Post').query({}, 1, 50, {
414
414
 
415
415
  A hidden, virtual, or array scalar field, a field of a to-many relation, or a relation the action does not include is
416
416
  rejected with a `400` error. Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, with a `<Model>Sort`
417
- alias emitted per model, and validated over HTTP against a generated `<Model>QuerySort` JSON schema. The default is
418
- `-createdAt,id`. See [resources.md](./references/resources.md) for the full rules.
417
+ alias emitted per model and validated over HTTP against a generated `<Model>QuerySort` JSON schema. The default is
418
+ `-createdAt,id`.
419
419
 
420
420
  ### Aggregating
421
421
 
@@ -436,9 +436,9 @@ additional queries, skipped for the periods holding no record.
436
436
 
437
437
  Any other field (string, boolean, enum, JSON, array, hidden, virtual, or a relation), an operator its type does not
438
438
  support, an empty selection, or a `dateField` that is not a date field is rejected with a `400` error. Selections are
439
- typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per model, and
440
- validated over HTTP against a generated `<Model>AggregateSelect` JSON schema. The response stays untyped JSON, since
441
- its shape follows the selection.
439
+ typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per model, and validated
440
+ over HTTP against a generated `<Model>AggregateSelect` JSON schema. The response stays untyped JSON, since its shape
441
+ follows the selection.
442
442
 
443
443
  ### Registering a custom route
444
444
 
@@ -73,13 +73,18 @@ Reads an OpenAPI v3 schema and generates TypeScript types and a typed client cla
73
73
  `@maxLength`, `@minimum`, `@maximum`, `@pattern`, `@format`).
74
74
  3. Deduplicates union types and extracts inline schemas to named exported types, including the ones carrying a
75
75
  description (i.e. `PostQuerySort`).
76
- 4. Hoists the enums the schema repeats inline into a single shared enum each, so every sortable field of every resource
77
- shares one `SortDirection` rather than declaring an `asc | desc` enum of its own.
78
- 5. Classifies all API paths into route groups: resources, auth, account, health, files, and custom.
79
- 6. Emits a typed client class extending `FetchClient<Paths>` with a getter for each route group. Resources with
76
+ 4. Hoists the schemas the document repeats inline into a shared definition each, so the generated types declare them
77
+ once and reference them everywhere else. This covers the enums (every sortable field of every resource shares one
78
+ `SortDirection` rather than declaring an `asc | desc` enum of its own) and the value a filterable field accepts
79
+ (`QueryFilterValue`, built from the plain `QueryFilterScalar`), while every property keeps its own description.
80
+ 5. Emits every enum as a constant object plus a type alias of its values, so both the member (`SortDirection.asc`) and
81
+ the plain literal (`'asc'`) are accepted wherever the enum is used. When the types are written to a declaration file
82
+ (`.d.ts`), the constant is declared rather than initialized, since such a file carries no runtime values.
83
+ 6. Classifies all API paths into route groups: resources, auth, account, health, files, and custom.
84
+ 7. Emits a typed client class extending `FetchClient<Paths>` with a getter for each route group. Resources with
80
85
  unsupported operations are excluded at compile time using `Omit`.
81
- 7. Formats all output with Prettier.
82
- 8. Writes files with an autogenerated header comment.
86
+ 8. Formats all output with Prettier.
87
+ 9. Writes files with an autogenerated header comment.
83
88
 
84
89
  **Examples:**
85
90
 
@@ -146,6 +151,35 @@ const sort: PostQuerySort = { createdAt: SortDirection.desc, title: SortDirectio
146
151
  const posts = await client.post.query({ sort, page: 1, size: 20 });
147
152
  ```
148
153
 
154
+ Enums are generated as a constant object together with a type alias of its values, so the members and the raw literals
155
+ are interchangeable and no import is needed for the literal form:
156
+
157
+ ```ts
158
+ export const SortDirection = { asc: 'asc', desc: 'desc' } as const;
159
+ export type SortDirection = (typeof SortDirection)[keyof typeof SortDirection];
160
+
161
+ // Both are valid and equally type safe
162
+ const byMember: PostQuerySort = { createdAt: SortDirection.desc };
163
+ const byLiteral: PostQuerySort = { createdAt: 'desc' };
164
+ ```
165
+
166
+ The value a filterable field accepts is declared once as well, rather than being spelled out again for every field of
167
+ every resource. A scalar field references `QueryFilterValue`, and a relation field adds the filter of the related
168
+ resource to the plain values it accepts:
169
+
170
+ ```ts
171
+ export type QueryFilterScalar = string | number | boolean | null;
172
+ export type QueryFilterValue = QueryFilterScalar | QueryFilterScalar[] | QueryCondition;
173
+
174
+ export type PostQueryFilter = {
175
+ /** @description Filter by the title field */
176
+ title?: QueryFilterValue;
177
+ /** @description Filter by the author relation, matching an id, a list of ids, or a nested User filter */
178
+ author?: QueryFilterScalar | QueryFilterScalar[] | UserQueryFilter | UserQueryFilter[];
179
+ // ...
180
+ };
181
+ ```
182
+
149
183
  ### Client file
150
184
 
151
185
  ```ts
@@ -256,11 +256,12 @@ The config object is frozen with `Object.freeze()` after loading to prevent runt
256
256
 
257
257
  #### OAuth2 general
258
258
 
259
- | Property | Type | Default | Description |
260
- |----------------------------------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------|
261
- | `SECURITY_OAUTH2_STATE_TTL` | integer | `600000` | OAuth2 state parameter TTL in milliseconds (default 10 min). |
262
- | `SECURITY_OAUTH2_REGISTRATION_ENABLED` | boolean | `true` | Allow registering new users via OAuth2 login. When `false`, only already existing users (matched by email) can log in via OAuth2. |
263
- | `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` | boolean | `false` | Download the user's avatar from the OAuth2 provider during registration and pass it as `avatarFile` to `registrationData`. |
259
+ | Property | Type | Default | Description |
260
+ |----------------------------------------------------------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------|
261
+ | `SECURITY_OAUTH2_STATE_TTL` | integer | `600000` | OAuth2 state parameter TTL in milliseconds (default 10 min). |
262
+ | `SECURITY_OAUTH2_REGISTRATION_ENABLED` | boolean | `true` | Allow registering new users via OAuth2 login. When `false`, only already existing users (matched by email) can log in via OAuth2. |
263
+ | `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` | boolean | `false` | Download the user's avatar from the OAuth2 provider during registration and pass it as `avatarFile` to `registrationData`. |
264
+ | `SECURITY_OAUTH2_CONNECTED_ACCOUNTS_KEEP_DATABASE_TABLE` | boolean | `false` | Keep the `ConnectedAccount` table even when every OAuth2 provider is disabled, so the links are not dropped by the next migration. |
264
265
 
265
266
  #### OAuth2 Google
266
267
 
@@ -280,6 +281,72 @@ The config object is frozen with `Object.freeze()` after loading to prevent runt
280
281
  | `SECURITY_OAUTH2_FACEBOOK_CLIENT_SECRET` | string? | - | Facebook OAuth2 client secret. |
281
282
  | `SECURITY_OAUTH2_FACEBOOK_USER_INFO_URL` | string | `'https://graph.facebook.com/me'` | Facebook user info endpoint. |
282
283
 
284
+ #### OAuth2 X (Twitter)
285
+
286
+ The app must be granted the email permission in the X developer portal, otherwise X refuses to return the
287
+ `confirmed_email` field and every login is rejected.
288
+
289
+ | Property | Type | Default | Description |
290
+ |-----------------------------------|---------|----------------------------------|------------------------------------------------------------------------|
291
+ | `SECURITY_OAUTH2_X_ENABLED` | boolean | `false` | Enable X OAuth2 provider. The flow always uses PKCE, which X requires. |
292
+ | `SECURITY_OAUTH2_X_CLIENT_ID` | string? | - | X OAuth2 client ID. |
293
+ | `SECURITY_OAUTH2_X_CLIENT_SECRET` | string? | - | X OAuth2 client secret. |
294
+ | `SECURITY_OAUTH2_X_USER_INFO_URL` | string | `'https://api.x.com/2/users/me'` | X user info endpoint. |
295
+
296
+ #### OAuth2 GitHub
297
+
298
+ | Property | Type | Default | Description |
299
+ |----------------------------------------|---------|---------------------------------|--------------------------------|
300
+ | `SECURITY_OAUTH2_GITHUB_ENABLED` | boolean | `false` | Enable GitHub OAuth2 provider. |
301
+ | `SECURITY_OAUTH2_GITHUB_CLIENT_ID` | string? | - | GitHub OAuth2 client ID. |
302
+ | `SECURITY_OAUTH2_GITHUB_CLIENT_SECRET` | string? | - | GitHub OAuth2 client secret. |
303
+ | `SECURITY_OAUTH2_GITHUB_USER_INFO_URL` | string | `'https://api.github.com/user'` | GitHub user info endpoint. |
304
+
305
+ #### OAuth2 GitLab
306
+
307
+ | Property | Type | Default | Description |
308
+ |----------------------------------------|---------|------------------------------------|---------------------------------------------------------------------------------------------|
309
+ | `SECURITY_OAUTH2_GITLAB_ENABLED` | boolean | `false` | Enable GitLab OAuth2 provider. |
310
+ | `SECURITY_OAUTH2_GITLAB_CLIENT_ID` | string? | - | GitLab OAuth2 client ID. |
311
+ | `SECURITY_OAUTH2_GITLAB_CLIENT_SECRET` | string? | - | GitLab OAuth2 client secret. |
312
+ | `SECURITY_OAUTH2_GITLAB_BASE_URL` | string | `'https://gitlab.com'` | Instance base URL for the authorize and token endpoints. Change it for self-managed GitLab. |
313
+ | `SECURITY_OAUTH2_GITLAB_USER_INFO_URL` | string | `'https://gitlab.com/api/v4/user'` | GitLab user info endpoint. |
314
+
315
+ #### OAuth2 LinkedIn
316
+
317
+ | Property | Type | Default | Description |
318
+ |------------------------------------------|---------|------------------------------------------|----------------------------------|
319
+ | `SECURITY_OAUTH2_LINKEDIN_ENABLED` | boolean | `false` | Enable LinkedIn OAuth2 provider. |
320
+ | `SECURITY_OAUTH2_LINKEDIN_CLIENT_ID` | string? | - | LinkedIn OAuth2 client ID. |
321
+ | `SECURITY_OAUTH2_LINKEDIN_CLIENT_SECRET` | string? | - | LinkedIn OAuth2 client secret. |
322
+ | `SECURITY_OAUTH2_LINKEDIN_USER_INFO_URL` | string | `'https://api.linkedin.com/v2/userinfo'` | LinkedIn user info endpoint. |
323
+
324
+ #### OAuth2 Apple
325
+
326
+ Apple expects the client secret to be a short-lived ES256 JWT: either set `SECURITY_OAUTH2_APPLE_CLIENT_SECRET` to one
327
+ you generated yourself, or provide the team ID, key ID and `.p8` private key and let the framework sign one at startup.
328
+
329
+ | Property | Type | Default | Description |
330
+ |--------------------------------------------------|---------|------------|--------------------------------------------------------------------------------------------------|
331
+ | `SECURITY_OAUTH2_APPLE_ENABLED` | boolean | `false` | Enable Apple OAuth2 provider. |
332
+ | `SECURITY_OAUTH2_APPLE_CLIENT_ID` | string? | - | Apple OAuth2 client ID (the Services ID identifier, e.g. `com.example.service`). |
333
+ | `SECURITY_OAUTH2_APPLE_CLIENT_SECRET` | string? | - | Pre-generated client secret JWT. Takes precedence over the signing key settings below. |
334
+ | `SECURITY_OAUTH2_APPLE_TEAM_ID` | string? | - | Apple developer team ID. |
335
+ | `SECURITY_OAUTH2_APPLE_KEY_ID` | string? | - | Identifier of the `.p8` signing key. |
336
+ | `SECURITY_OAUTH2_APPLE_PRIVATE_KEY` | string? | - | Contents of the `.p8` private key (PEM). Escaped `\n` sequences are restored. |
337
+ | `SECURITY_OAUTH2_APPLE_PRIVATE_KEY_PATH` | string? | - | Path to the `.p8` private key file. Used when the inline key is not set. |
338
+ | `SECURITY_OAUTH2_APPLE_CLIENT_SECRET_EXPIRES_IN` | integer | `15552000` | Lifetime in seconds of the generated secret. Apple rejects anything above `15777000` (6 months). |
339
+
340
+ #### OAuth2 Microsoft
341
+
342
+ | Property | Type | Default | Description |
343
+ |-------------------------------------------|---------|-----------------------------------------|----------------------------------------------------------------------------------------------------------|
344
+ | `SECURITY_OAUTH2_MICROSOFT_ENABLED` | boolean | `false` | Enable Microsoft OAuth2 provider. |
345
+ | `SECURITY_OAUTH2_MICROSOFT_CLIENT_ID` | string? | - | Microsoft OAuth2 client ID (the Entra ID application ID). |
346
+ | `SECURITY_OAUTH2_MICROSOFT_CLIENT_SECRET` | string? | - | Microsoft OAuth2 client secret. |
347
+ | `SECURITY_OAUTH2_MICROSOFT_TENANT` | string | `'common'` | Tenant used in the authorize and token endpoints: a tenant ID, `common`, `organizations` or `consumers`. |
348
+ | `SECURITY_OAUTH2_MICROSOFT_USER_INFO_URL` | string | `'https://graph.microsoft.com/v1.0/me'` | Microsoft Graph user info endpoint. |
349
+
283
350
  #### OAuth2 Custom (OpenID Connect)
284
351
 
285
352
  | Property | Type | Default | Description |
@@ -73,7 +73,7 @@ if `SECURITY_JWT_SECRET` is set.
73
73
  ```json
74
74
  {
75
75
  "scope": "auth | refresh | 2fa",
76
- "source": "password | oauth2Google | oauth2Facebook | oauth2Custom | apiKey | basic",
76
+ "source": "password | apiKey | basic | oauth2Google | oauth2Facebook | oauth2X | oauth2Github | oauth2Gitlab | oauth2Linkedin | oauth2Apple | oauth2Microsoft | oauth2Custom",
77
77
  "username": "User email (e.g. admin@example.com)",
78
78
  "sub": "User ID (e.g. 123)",
79
79
  "iat": "Issued at timestamp (e.g. 1774623924234)"
@@ -187,8 +187,8 @@ Default delimiter is `AK`, so a key looks like: `42AKa1b2c3d4e5f6...`
187
187
 
188
188
  ## OAuth2 authentication
189
189
 
190
- Appweaver supports OAuth2 login with Google, Facebook, and a custom OpenID Connect provider. All OAuth2 providers follow
191
- the same flow pattern.
190
+ Appweaver supports OAuth2 login with Google, Facebook, X, GitHub, GitLab, LinkedIn, Apple, Microsoft, and a custom
191
+ OpenID Connect provider. All of them are built from the same `createOAuth2Plugin` factory and follow the same flow.
192
192
 
193
193
  ### OAuth2 flow
194
194
 
@@ -217,140 +217,74 @@ the same flow pattern.
217
217
  9. Server finds the user by email and invokes the optional checkOAuth2User callback
218
218
  (aborts with an error when the callback returns a string or an Error).
219
219
  New users are registered unless SECURITY_OAUTH2_REGISTRATION_ENABLED=false
220
- 10. Server generates an authentication OTT
220
+ 10. Server generates an authentication OTT, flagging it when the password has to be confirmed
221
221
  11. Server redirects to the original URL with the token:
222
- -> https://myapp.com/dashboard?token={ott}
223
-
224
- 12. Client exchanges the OTT for JWT tokens:
225
- POST /auth/exchange-token { token: "{ott}" }
222
+ -> https://myapp.com/dashboard?token={ott}[&passwordRequired=true]
223
+ 12. Client exchanges the OTT for JWT tokens, adding the password when it was asked for:
224
+ POST /auth/exchange-token { token: "{ott}", password: "..." }
226
225
  -> { accessToken, refreshToken }
226
+ 13. Server records the provider account in the ConnectedAccount table
227
227
  ```
228
228
 
229
- ### Google OAuth2
230
-
231
- **Configuration:**
232
-
233
- ```json
234
- {
235
- "config": {
236
- "security": {
237
- "oauth2": {
238
- "google": {
239
- "enabled": true,
240
- "clientId": "your-google-client-id",
241
- "clientSecret": "your-google-client-secret"
242
- }
243
- }
244
- }
245
- }
246
- }
247
- ```
248
-
249
- Or via environment variables:
250
-
251
- ```env
252
- SECURITY_OAUTH2_GOOGLE_ENABLED=true
253
- SECURITY_OAUTH2_GOOGLE_CLIENT_ID=your-google-client-id
254
- SECURITY_OAUTH2_GOOGLE_CLIENT_SECRET=your-google-client-secret
255
- ```
256
-
257
- **Routes:**
258
-
259
- | Method | Path | Description |
260
- |--------|-------------------------------|--------------------------------------------------------------------------|
261
- | `GET` | `/auth/login/google` | Redirect to Google consent screen. Query: `redirectToUrl`. |
262
- | `GET` | `/auth/login/google/callback` | Google callback. Exchanges code, creates/finds user, redirects with OTT. |
263
-
264
- **Scopes**: `profile`, `email`
265
-
266
- **User info extracted**: `email`, `given_name` (firstName), `family_name` (lastName), `picture` (avatarUrl)
267
-
268
- **Google Cloud Console setup:**
269
-
270
- 1. Create OAuth 2.0 credentials in the Google Cloud Console
271
- 2. Set the authorized redirect URI to: `{APP_HOSTNAME}{SERVER_API_PREFIX}/auth/login/google/callback`
272
- (e.g. `https://api.myapp.com/api/auth/login/google/callback`)
273
-
274
- ### Facebook OAuth2
275
-
276
- **Configuration:**
277
-
278
- ```json
279
- {
280
- "config": {
281
- "security": {
282
- "oauth2": {
283
- "facebook": {
284
- "enabled": true,
285
- "clientId": "your-facebook-app-id",
286
- "clientSecret": "your-facebook-app-secret"
287
- }
288
- }
289
- }
290
- }
291
- }
292
- ```
293
-
294
- Or via environment variables:
295
-
296
- ```env
297
- SECURITY_OAUTH2_FACEBOOK_ENABLED=true
298
- SECURITY_OAUTH2_FACEBOOK_CLIENT_ID=your-facebook-app-id
299
- SECURITY_OAUTH2_FACEBOOK_CLIENT_SECRET=your-facebook-app-secret
300
- ```
301
-
302
- **Routes:**
303
-
304
- | Method | Path | Description |
305
- |--------|---------------------------------|----------------------------------------------------------------------------|
306
- | `GET` | `/auth/login/facebook` | Redirect to Facebook login. Query: `redirectToUrl`. |
307
- | `GET` | `/auth/login/facebook/callback` | Facebook callback. Exchanges code, creates/finds user, redirects with OTT. |
308
-
309
- **Scopes**: `public_profile`, `email`
310
-
311
- **User info extracted**: `email`, `name` (split into firstName/lastName), `picture` (avatarUrl)
312
-
313
- **Facebook Developer Console setup:**
314
-
315
- 1. Create an app in the Facebook Developer Console
316
- 2. Add Facebook Login product
317
- 3. Set the valid OAuth redirect URI to: `{APP_HOSTNAME}{SERVER_API_PREFIX}/auth/login/facebook/callback`
318
-
319
- ### Custom OAuth2 (OpenID Connect)
320
-
321
- For any OpenID Connect-compatible provider (Keycloak, Auth0, etc.).
322
-
323
- **Configuration:**
324
-
325
- ```json
326
- {
327
- "config": {
328
- "security": {
329
- "oauth2": {
330
- "custom": {
331
- "enabled": true,
332
- "clientId": "your-client-id",
333
- "clientSecret": "your-client-secret",
334
- "issuer": "https://keycloak.example.com/realms/myrealm"
335
- }
336
- }
337
- }
338
- }
339
- }
340
- ```
341
-
342
- **Routes:**
343
-
344
- | Method | Path | Description |
345
- |--------|-------------------------------|------------------------------------------------------|
346
- | `GET` | `/auth/login/custom` | Redirect to custom provider. Query: `redirectToUrl`. |
347
- | `GET` | `/auth/login/custom/callback` | Custom provider callback. |
348
-
349
- **Scopes**: `openid`, `profile`, `email`
350
-
351
- **User info endpoint**: `{issuer}/protocol/openid-connect/userinfo`
352
-
353
- **Standard claims expected**: `sub`, `email`, `given_name`, `family_name`, `picture` (optional, avatarUrl)
229
+ ### Providers
230
+
231
+ Every provider is disabled by default and enabled with `SECURITY_OAUTH2_<NAME>_ENABLED` plus `_CLIENT_ID` and
232
+ `_CLIENT_SECRET` (JSON: `security.oauth2.<name>`). Enabling one registers `GET /auth/login/<name>` and
233
+ `/auth/login/<name>/callback`. Register the callback URL `{APP_HOSTNAME}{SERVER_API_PREFIX}/auth/login/<name>/callback`
234
+ with the provider.
235
+
236
+ | `<name>` | Scopes | User info source |
237
+ |-------------|-------------------------------------------|---------------------------------------------|
238
+ | `google` | `profile`, `email` | `googleapis.com/oauth2/v2/userinfo` |
239
+ | `facebook` | `public_profile`, `email` | `graph.facebook.com/me` |
240
+ | `x` | `users.read`, `tweet.read` | `api.x.com/2/users/me` |
241
+ | `github` | `read:user`, `user:email` | `api.github.com/user` |
242
+ | `gitlab` | `read_user` | `gitlab.com/api/v4/user` |
243
+ | `linkedin` | `openid`, `profile`, `email` | `api.linkedin.com/v2/userinfo` |
244
+ | `apple` | `name`, `email` | the `id_token` (no endpoint) |
245
+ | `microsoft` | `openid`, `profile`, `email`, `User.Read` | `graph.microsoft.com/v1.0/me` |
246
+ | `custom` | `openid`, `profile`, `email` | `{issuer}/protocol/openid-connect/userinfo` |
247
+
248
+ ### Provider anomalies
249
+
250
+ - **X only releases the email address to approved apps.** The `confirmed_email` field is requested explicitly, but X
251
+ serves it solely to apps granted the email permission in the developer portal — without it the call fails and the
252
+ login is rejected with a 403.
253
+ - **Apple's callback is a `POST`** (`response_mode=form_post`), the identity comes from the `id_token`, and the client
254
+ secret is an ES256 JWT signed at startup from `teamId`/`keyId`/the `.p8` key — expiring after
255
+ `clientSecretExpiresIn` seconds, so a longer-running process needs a restart. Set `clientSecret` directly to use your
256
+ own. The name is sent **only on the first authorization**; later logins yield empty `firstName`/`lastName`.
257
+ - **GitHub** falls back to `/user/emails` for the primary-verified address when the profile email is private and rejects
258
+ accounts with no verified address.
259
+ - **GitLab** self-managed instances need `SECURITY_OAUTH2_GITLAB_BASE_URL` and `_USER_INFO_URL`.
260
+ - **Microsoft** uses `SECURITY_OAUTH2_MICROSOFT_TENANT` (default `common`) in its endpoints, and its Graph photo is an
261
+ authenticated binary, so it arrives as `avatarFile` instead of `avatarUrl`.
262
+ - **Custom** targets any OpenID Connect provider (Keycloak, Auth0, …) and needs `issuer` instead of preset endpoints.
263
+
264
+ ### Account takeover protection
265
+
266
+ An OAuth2 sign-in matches an existing user by email, so a provider account carrying someone else's address must not by
267
+ itself unlock a password-protected account. The first time a provider is linked to a user that has a `passwordHash`, the
268
+ redirect carries `&passwordRequired=true` and `POST /auth/exchange-token` rejects the token unless
269
+ `{ token, password }` is sent. The one-time token is spent either way, so a wrong password means restarting the flow.
270
+
271
+ Confirmation happens once: the pairing is stored in `ConnectedAccount` and later sign-ins pass through. Users without a
272
+ password, and users being registered by the current sign-in, are never asked. There is no flag to switch this off — the
273
+ check only ever fires where skipping it would hand the account over. Without a `ConnectedAccount` table there is nowhere
274
+ to record the confirmation, so the password is asked on every sign-in instead.
275
+
276
+ A local `verifiedEmail: false` does not block the sign-in — the provider vouches for the address and the password
277
+ confirms the account, which together prove more than local verification would. The address is marked verified once the
278
+ exchange succeeds.
279
+
280
+ ### Connected accounts
281
+
282
+ `ConnectedAccount` pairs a provider account with a local user: `provider`, `providerAccountId`, `scope`, `lastLoginAt`
283
+ (`createdAt` holds the link date) and a relation to the auth model, indexed on `[provider, providerAccountId]`. A
284
+ provider account belongs to one user only — relinking it elsewhere fails with a 403.
285
+
286
+ The table exists when any OAuth2 provider is enabled; `SECURITY_OAUTH2_CONNECTED_ACCOUNTS_KEEP_DATABASE_TABLE=true`
287
+ keeps it after disabling OAuth2, like `SECURITY_API_KEY_KEEP_DATABASE_TABLE` does for API keys.
354
288
 
355
289
  ### OAuth2 registration control and hooks
356
290