@startino/better-auth-oidc 0.1.7 → 0.1.8
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 +366 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
OIDC-only SSO plugin for [Better Auth](https://www.better-auth.com/). Runs on any JavaScript runtime without Node.js-specific APIs.
|
|
4
4
|
|
|
5
|
-
## Why
|
|
5
|
+
## Why this exists
|
|
6
6
|
|
|
7
7
|
The official [`@better-auth/sso`](https://www.better-auth.com/docs/plugins/sso) plugin imports `samlify` at module load, which requires Node.js-only APIs (`node:crypto`, `node:buffer`). This breaks in edge runtimes, serverless environments, or any platform without full Node.js compatibility, even if you only need OIDC.
|
|
8
8
|
|
|
9
|
-
This package extracts the OIDC code paths into a standalone
|
|
9
|
+
This package extracts the OIDC code paths into a standalone plugin. SAML code and Node.js dependencies are removed entirely.
|
|
10
10
|
|
|
11
11
|
## Runtime compatibility
|
|
12
12
|
|
|
@@ -32,7 +32,7 @@ npm install @startino/better-auth-oidc
|
|
|
32
32
|
pnpm add @startino/better-auth-oidc
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Peer dependencies: `better-auth` (>=1.4.0)
|
|
35
|
+
Peer dependencies: `better-auth` (>=1.4.0), `better-call` (>=1.0.0).
|
|
36
36
|
|
|
37
37
|
## Quick start
|
|
38
38
|
|
|
@@ -92,49 +92,348 @@ await client.signIn.sso({
|
|
|
92
92
|
});
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## SSO flow overview
|
|
96
|
+
|
|
97
|
+
Here is what happens during an SSO sign-in:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
┌──────────┐ ┌──────────────┐ ┌─────────┐ ┌─────────┐
|
|
101
|
+
│ Client │ │ Auth Server │ │ IdP │ │ App │
|
|
102
|
+
└────┬─────┘ └──────┬───────┘ └────┬────┘ └────┬────┘
|
|
103
|
+
│ POST /sign-in/sso│ │ │
|
|
104
|
+
│──────────────────>│ │ │
|
|
105
|
+
│ │ 302 → authorize │ │
|
|
106
|
+
│ │─────────────────>│ │
|
|
107
|
+
│ │ │ │
|
|
108
|
+
│ │ User logs in │ │
|
|
109
|
+
│ │ │ │
|
|
110
|
+
│ │ GET /sso/callback│ │
|
|
111
|
+
│ │<─────────────────│ │
|
|
112
|
+
│ │ │ │
|
|
113
|
+
│ │ Exchange code │ │
|
|
114
|
+
│ │ for tokens │ │
|
|
115
|
+
│ │─────────────────>│ │
|
|
116
|
+
│ │<─────────────────│ │
|
|
117
|
+
│ │ │ │
|
|
118
|
+
│ │ Create/link user │ │
|
|
119
|
+
│ │ Create session │ │
|
|
120
|
+
│ │ Generate OTT │ │
|
|
121
|
+
│ │ │ │
|
|
122
|
+
│ │ 302 → callbackURL?ott=TOKEN │
|
|
123
|
+
│ │─────────────────────────────────>│
|
|
124
|
+
│ │ │ │
|
|
125
|
+
│ │ GET /sso/verify-ott?token=… │
|
|
126
|
+
│ │<─────────────────────────────────│
|
|
127
|
+
│ │ │ │
|
|
128
|
+
│ │ Set session cookie on app domain│
|
|
129
|
+
│ │─────────────────────────────────>│
|
|
130
|
+
└ └ └ └
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
1. **Sign-in request.** The client calls `POST /sign-in/sso` with an email, domain, or provider ID. The plugin finds the matching SSO provider and builds the OIDC authorization URL.
|
|
134
|
+
2. **Redirect to IdP.** The user is redirected to the identity provider (Okta, Entra ID, Google Workspace, etc.) to authenticate.
|
|
135
|
+
3. **Callback.** The IdP redirects back to `GET /sso/callback/:providerId` with an authorization code.
|
|
136
|
+
4. **Token exchange.** The plugin exchanges the code for an ID token and (optionally) access token. The ID token is verified against the provider's JWKS.
|
|
137
|
+
5. **User creation/linking.** A user account is created or linked. Organization membership is assigned if configured.
|
|
138
|
+
6. **OTT generation.** A one-time token is created (32 random characters, 60-second TTL) and appended to the callback URL as `?ott=TOKEN`.
|
|
139
|
+
7. **OTT verification.** The app calls `GET /sso/verify-ott?token=TOKEN` through its proxy to the auth server. This sets the session cookie on the app's domain.
|
|
140
|
+
|
|
141
|
+
## Cross-domain session transfer (OTT)
|
|
142
|
+
|
|
143
|
+
### The problem
|
|
144
|
+
|
|
145
|
+
In proxy-based architectures (SvelteKit + Convex, Next.js + external auth server, etc.), the auth server runs on a different domain than your app. The SSO callback hits the auth server's domain, so the session cookie is set there, not on the app's domain.
|
|
146
|
+
|
|
147
|
+
Without OTT, the user would be redirected back to the app with no session.
|
|
148
|
+
|
|
149
|
+
### How it works
|
|
150
|
+
|
|
151
|
+
After the SSO callback processes the sign-in, the plugin:
|
|
152
|
+
|
|
153
|
+
1. Creates a session and sets the cookie on the auth server's domain
|
|
154
|
+
2. Generates a one-time token (32 characters, stored in the `verification` table, expires in 60 seconds)
|
|
155
|
+
3. Redirects to your `callbackURL` with `?ott=TOKEN` appended
|
|
156
|
+
|
|
157
|
+
Your app then verifies the OTT through its auth proxy, which sets the session cookie on the correct domain.
|
|
158
|
+
|
|
159
|
+
### Client-side implementation
|
|
160
|
+
|
|
161
|
+
On whichever page your `callbackURL` points to, check for the `ott` query parameter and verify it:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// Example: SvelteKit +page.ts or +page.server.ts
|
|
165
|
+
import { redirect } from "@sveltejs/kit";
|
|
166
|
+
|
|
167
|
+
export const load = async ({ url, fetch }) => {
|
|
168
|
+
const ott = url.searchParams.get("ott");
|
|
169
|
+
if (ott) {
|
|
170
|
+
// Call through your proxy so the cookie is set on your domain
|
|
171
|
+
const res = await fetch(`/api/auth/sso/verify-ott?token=${ott}`);
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
throw redirect(303, "/signin?error=sso-verification-failed");
|
|
174
|
+
}
|
|
175
|
+
// Remove the ott param from the URL
|
|
176
|
+
throw redirect(303, url.pathname);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// Example: Next.js middleware or page
|
|
183
|
+
const ott = searchParams.get("ott");
|
|
184
|
+
if (ott) {
|
|
185
|
+
await fetch(`${process.env.NEXT_PUBLIC_URL}/api/auth/sso/verify-ott?token=${ott}`, {
|
|
186
|
+
credentials: "include",
|
|
187
|
+
});
|
|
188
|
+
redirect("/dashboard");
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The key requirement: the `verify-ott` call must go through your app's proxy to the auth server so the `Set-Cookie` header lands on your app's domain.
|
|
193
|
+
|
|
95
194
|
## Configuration options
|
|
96
195
|
|
|
196
|
+
### `SSOOptions`
|
|
197
|
+
|
|
97
198
|
| Option | Type | Default | Description |
|
|
98
199
|
|---|---|---|---|
|
|
99
|
-
| `provisionUser` | `
|
|
100
|
-
| `organizationProvisioning` | `object` | - | Auto-assign users to
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `domainVerification` | `
|
|
111
|
-
|
|
112
|
-
|
|
200
|
+
| `provisionUser` | `(data) => void` | - | Called after a user signs in or signs up via SSO. Receives `{ user, userInfo, token, provider }`. Use for custom role assignment, feature flags, syncing to external systems, etc. |
|
|
201
|
+
| `organizationProvisioning` | `object` | - | Auto-assign users to organizations based on SSO provider. See [Organization provisioning](#organization-provisioning). |
|
|
202
|
+
| `defaultSSO` | `Array<{ domain, providerId, oidcConfig? }>` | - | Default provider configs (takes precedence over DB). Useful for development/testing without storing providers in the database. |
|
|
203
|
+
| `defaultOverrideUserInfo` | `boolean` | `false` | Override user name/image with provider data on every sign-in, not just the first. |
|
|
204
|
+
| `disableImplicitSignUp` | `boolean` | `false` | Reject new users unless `requestSignUp: true` is passed in the sign-in call. |
|
|
205
|
+
| `modelName` | `string` | `"ssoProvider"` | Custom table name for SSO providers in the database. |
|
|
206
|
+
| `fields` | `object` | - | Remap column names: `{ issuer?, oidcConfig?, userId?, providerId?, organizationId?, domain? }`. |
|
|
207
|
+
| `providersLimit` | `number \| (user) => number` | `10` | Max providers a user can register. Set to `0` to disable registration entirely. |
|
|
208
|
+
| `trustEmailVerified` | `boolean` | `false` | Trust the `email_verified` claim from the IdP. **Deprecated.** See [Account linking](#account-linking-and-trustemailverified). |
|
|
209
|
+
| `domainVerification` | `object` | - | DNS-based domain ownership verification. See [Domain verification](#domain-verification). |
|
|
210
|
+
| `domainVerification.enabled` | `boolean` | `false` | Enable/disable domain verification. |
|
|
211
|
+
| `domainVerification.tokenPrefix` | `string` | `"better-auth-token"` | Prefix for the DNS TXT record. |
|
|
212
|
+
|
|
213
|
+
## OIDC configuration
|
|
214
|
+
|
|
215
|
+
When registering or updating a provider, the `oidcConfig` object controls how the plugin communicates with the identity provider.
|
|
216
|
+
|
|
217
|
+
| Field | Type | Required | Default | Description |
|
|
218
|
+
|---|---|---|---|---|
|
|
219
|
+
| `clientId` | `string` | Yes | - | OAuth client ID from your IdP. |
|
|
220
|
+
| `clientSecret` | `string` | Yes | - | OAuth client secret from your IdP. |
|
|
221
|
+
| `issuer` | `string` | - | Parent `issuer` | The OIDC issuer URL. Usually set on the provider, not inside `oidcConfig`. |
|
|
222
|
+
| `discoveryEndpoint` | `string` | No | `{issuer}/.well-known/openid-configuration` | Override the discovery URL if your IdP uses a non-standard path. |
|
|
223
|
+
| `skipDiscovery` | `boolean` | No | `false` | Skip automatic OIDC discovery. When `true`, you must provide `authorizationEndpoint`, `tokenEndpoint`, and `jwksEndpoint` manually. |
|
|
224
|
+
| `authorizationEndpoint` | `string` | If `skipDiscovery` | Discovered | OAuth authorization URL. |
|
|
225
|
+
| `tokenEndpoint` | `string` | If `skipDiscovery` | Discovered | OAuth token exchange URL. |
|
|
226
|
+
| `jwksEndpoint` | `string` | If `skipDiscovery` | Discovered | JWKS URL for ID token signature verification. |
|
|
227
|
+
| `userInfoEndpoint` | `string` | No | Discovered | UserInfo endpoint. Only needed if ID token claims are insufficient. |
|
|
228
|
+
| `tokenEndpointAuthentication` | `"client_secret_basic" \| "client_secret_post"` | No | Auto-detected | How to authenticate at the token endpoint. `client_secret_basic` sends credentials in the Authorization header. `client_secret_post` sends them in the request body. The plugin auto-selects based on IdP discovery, preferring `client_secret_basic`. |
|
|
229
|
+
| `pkce` | `boolean` | No | `true` | Use PKCE (Proof Key for Code Exchange) with S256 challenge method. Recommended to leave enabled. |
|
|
230
|
+
| `scopes` | `string[]` | No | `["openid", "email", "profile"]` | OAuth scopes to request. |
|
|
231
|
+
| `overrideUserInfo` | `boolean` | No | `false` | Override stored user info with provider data on this specific provider. |
|
|
232
|
+
| `mapping` | `OIDCMapping` | No | See below | Map non-standard claim names to expected fields. |
|
|
233
|
+
|
|
234
|
+
### Field mapping
|
|
235
|
+
|
|
236
|
+
If your IdP returns claims with non-standard names, use the `mapping` object to tell the plugin where to find each field.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
oidcConfig: {
|
|
240
|
+
clientId: "...",
|
|
241
|
+
clientSecret: "...",
|
|
242
|
+
mapping: {
|
|
243
|
+
id: "sub", // Default: "sub"
|
|
244
|
+
email: "email", // Default: "email"
|
|
245
|
+
emailVerified: "email_verified", // Default: "email_verified"
|
|
246
|
+
name: "name", // Default: "name"
|
|
247
|
+
image: "picture", // Default: "picture"
|
|
248
|
+
extraFields: { // Map additional claims
|
|
249
|
+
department: "custom:department",
|
|
250
|
+
employeeId: "custom:employee_id",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The `extraFields` values are passed through to the `provisionUser` callback in `userInfo`.
|
|
113
257
|
|
|
114
258
|
## Endpoints
|
|
115
259
|
|
|
116
|
-
| Endpoint | Method | Description |
|
|
117
|
-
|
|
118
|
-
| `/sso/register` | POST | Register a new OIDC provider |
|
|
119
|
-
| `/sign-in/sso` | POST | Initiate SSO sign-in
|
|
120
|
-
| `/sso/callback/:providerId` | GET | OAuth2 callback handler |
|
|
121
|
-
| `/sso/
|
|
122
|
-
| `/sso/
|
|
123
|
-
| `/sso/
|
|
124
|
-
| `/sso/
|
|
125
|
-
| `/sso/
|
|
126
|
-
| `/sso/
|
|
260
|
+
| Endpoint | Method | Auth | Description |
|
|
261
|
+
|---|---|---|---|
|
|
262
|
+
| `/sso/register` | POST | Yes | Register a new OIDC provider. |
|
|
263
|
+
| `/sign-in/sso` | POST | No | Initiate SSO sign-in. Accepts `email`, `providerId`, `domain`, or `organizationSlug` to find the provider. Returns `{ url, redirect: true }`. |
|
|
264
|
+
| `/sso/callback/:providerId` | GET | No | OAuth2 callback handler. Exchanges code for tokens, creates/links user, generates OTT, redirects to `callbackURL`. |
|
|
265
|
+
| `/sso/verify-ott` | GET | No | Exchange a one-time token for a session cookie. Query: `?token=TOKEN`. |
|
|
266
|
+
| `/sso/providers` | GET | Yes | List providers the authenticated user has access to. |
|
|
267
|
+
| `/sso/get-provider` | GET | Yes | Get a single provider. Query: `?providerId=ID`. |
|
|
268
|
+
| `/sso/update-provider` | POST | Yes | Update provider fields (partial update). Changing `domain` resets `domainVerified`. |
|
|
269
|
+
| `/sso/delete-provider` | POST | Yes | Delete a provider. Body: `{ providerId }`. |
|
|
270
|
+
| `/sso/request-domain-verification` | POST | Yes | Request a domain verification token (if enabled). |
|
|
271
|
+
| `/sso/verify-domain` | POST | Yes | Verify domain via DNS TXT record lookup (if enabled). |
|
|
272
|
+
|
|
273
|
+
### Sign-in options
|
|
274
|
+
|
|
275
|
+
The `POST /sign-in/sso` endpoint accepts these fields:
|
|
276
|
+
|
|
277
|
+
| Field | Type | Required | Description |
|
|
278
|
+
|---|---|---|---|
|
|
279
|
+
| `email` | `string` | One of these | Extracts the domain to find a matching provider. |
|
|
280
|
+
| `providerId` | `string` | | Direct provider reference. |
|
|
281
|
+
| `domain` | `string` | | Direct domain reference. |
|
|
282
|
+
| `organizationSlug` | `string` | | Finds the org, then finds a provider linked to it. |
|
|
283
|
+
| `callbackURL` | `string` | Yes | Where to redirect after sign-in. The OTT is appended here. |
|
|
284
|
+
| `errorCallbackURL` | `string` | No | Redirect on error. Falls back to `callbackURL`. |
|
|
285
|
+
| `newUserCallbackURL` | `string` | No | Redirect for first-time users. Falls back to `callbackURL`. |
|
|
286
|
+
| `scopes` | `string[]` | No | Override the provider's default scopes for this sign-in. |
|
|
287
|
+
| `loginHint` | `string` | No | Pre-fill the email/username at the IdP login screen. |
|
|
288
|
+
| `requestSignUp` | `boolean` | No | Required when `disableImplicitSignUp` is `true`. |
|
|
289
|
+
|
|
290
|
+
## Organization provisioning
|
|
291
|
+
|
|
292
|
+
If you use Better Auth's [organization plugin](https://www.better-auth.com/docs/plugins/organization), you can auto-assign users to organizations when they sign in via SSO.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
oidcSso({
|
|
296
|
+
organizationProvisioning: {
|
|
297
|
+
defaultRole: "member", // "member" or "admin"
|
|
298
|
+
getRole: async ({ user, userInfo, token, provider }) => {
|
|
299
|
+
// Custom logic, e.g. check a group claim
|
|
300
|
+
const groups = userInfo["groups"] || [];
|
|
301
|
+
return groups.includes("admins") ? "admin" : "member";
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
How it works:
|
|
308
|
+
|
|
309
|
+
1. When registering a provider, pass `organizationId` to link it to an org.
|
|
310
|
+
2. On sign-in, after the user account is created or linked, the plugin checks if the provider has an `organizationId`.
|
|
311
|
+
3. If the user is not already a member of that org, they are added with the role from `getRole()` (or `defaultRole` if no function provided).
|
|
312
|
+
|
|
313
|
+
Set `organizationProvisioning.disabled: true` to turn off auto-provisioning while keeping the config.
|
|
314
|
+
|
|
315
|
+
### `provisionUser` callback
|
|
316
|
+
|
|
317
|
+
For more control beyond org assignment, use `provisionUser`. It runs after the user is created/linked and after org assignment.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
oidcSso({
|
|
321
|
+
provisionUser: async ({ user, userInfo, token, provider }) => {
|
|
322
|
+
// Sync to your own database, assign feature flags, send a welcome email, etc.
|
|
323
|
+
await myDb.upsertUser({
|
|
324
|
+
id: user.id,
|
|
325
|
+
department: userInfo.department,
|
|
326
|
+
ssoProvider: provider.providerId,
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The `token` parameter contains the OAuth2 tokens (access token, refresh token, ID token) from the provider if you need to make further API calls.
|
|
333
|
+
|
|
334
|
+
## Multi-domain SSO
|
|
335
|
+
|
|
336
|
+
A single SSO provider can serve multiple email domains. Pass a comma-separated string:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
await client.sso.register({
|
|
340
|
+
providerId: "acme-corp",
|
|
341
|
+
issuer: "https://acme.okta.com",
|
|
342
|
+
domain: "acme.com,subsidiary.com,acquired-co.com",
|
|
343
|
+
oidcConfig: { clientId: "...", clientSecret: "..." },
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
When a user signs in with `user@subsidiary.com`, the plugin finds the provider by checking each domain in the list. Subdomain matching is also supported: `user@eng.acme.com` matches `acme.com`.
|
|
348
|
+
|
|
349
|
+
The `domainMatches(searchDomain, domainList)` utility function handles this logic. It splits on commas, trims whitespace, and checks for exact or subdomain matches (case-insensitive).
|
|
127
350
|
|
|
128
351
|
## Domain verification
|
|
129
352
|
|
|
130
|
-
When `domainVerification.enabled` is `true`, new providers require DNS-based domain ownership
|
|
353
|
+
When `domainVerification.enabled` is `true`, new providers require DNS-based domain ownership proof before sign-ins are allowed.
|
|
354
|
+
|
|
355
|
+
### Setup flow
|
|
131
356
|
|
|
132
|
-
1. Register a provider
|
|
133
|
-
|
|
134
|
-
|
|
357
|
+
1. **Register a provider.** The response includes `domainVerificationToken` and `domainVerified: false`.
|
|
358
|
+
|
|
359
|
+
2. **Create a DNS TXT record:**
|
|
360
|
+
```
|
|
361
|
+
Name: _better-auth-token-<providerId>.<domain>
|
|
362
|
+
Value: _better-auth-token-<providerId>=<token>
|
|
363
|
+
```
|
|
364
|
+
Example for provider `okta-acme` on `acme.com`:
|
|
365
|
+
```
|
|
366
|
+
_better-auth-token-okta-acme.acme.com TXT "_better-auth-token-okta-acme=abc123xyz..."
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
3. **Call the verify endpoint.** The plugin resolves the TXT record via DNS-over-HTTPS (Cloudflare) and confirms ownership.
|
|
135
370
|
|
|
136
371
|
No `node:dns` required. Verification works on any runtime with `fetch`.
|
|
137
372
|
|
|
373
|
+
### How DNS verification works
|
|
374
|
+
|
|
375
|
+
- The plugin queries `https://cloudflare-dns.com/dns-query` with the TXT record name.
|
|
376
|
+
- The record name follows RFC 8552 (underscore-prefixed labels): `_{tokenPrefix}-{providerId}.{domain}`.
|
|
377
|
+
- The token prefix defaults to `better-auth-token`. You can change it in the config.
|
|
378
|
+
- Verification tokens last 7 days. You can re-request them if expired.
|
|
379
|
+
- If the domain changes on a provider, `domainVerified` resets to `false`.
|
|
380
|
+
- DNS labels are capped at 63 characters. The plugin validates this before lookup.
|
|
381
|
+
|
|
382
|
+
## OIDC discovery
|
|
383
|
+
|
|
384
|
+
By default, the plugin fetches the provider's `/.well-known/openid-configuration` document when you register a provider. This auto-fills:
|
|
385
|
+
|
|
386
|
+
- `authorizationEndpoint`
|
|
387
|
+
- `tokenEndpoint`
|
|
388
|
+
- `jwksEndpoint`
|
|
389
|
+
- `userInfoEndpoint`
|
|
390
|
+
- `tokenEndpointAuthentication` (selected from `token_endpoint_auth_methods_supported`)
|
|
391
|
+
|
|
392
|
+
If the provider's discovery document is missing required fields (`issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`), registration fails with a descriptive error.
|
|
393
|
+
|
|
394
|
+
### Skipping discovery
|
|
395
|
+
|
|
396
|
+
Some providers don't support standard OIDC discovery. Set `skipDiscovery: true` and provide endpoints manually:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
oidcConfig: {
|
|
400
|
+
clientId: "...",
|
|
401
|
+
clientSecret: "...",
|
|
402
|
+
skipDiscovery: true,
|
|
403
|
+
authorizationEndpoint: "https://idp.example.com/authorize",
|
|
404
|
+
tokenEndpoint: "https://idp.example.com/token",
|
|
405
|
+
jwksEndpoint: "https://idp.example.com/.well-known/jwks.json",
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Custom discovery endpoint
|
|
410
|
+
|
|
411
|
+
If your IdP uses a non-standard discovery path:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
oidcConfig: {
|
|
415
|
+
clientId: "...",
|
|
416
|
+
clientSecret: "...",
|
|
417
|
+
discoveryEndpoint: "https://idp.example.com/custom/.well-known/openid-configuration",
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Runtime discovery
|
|
422
|
+
|
|
423
|
+
If the stored config is missing `tokenEndpoint` or `jwksEndpoint` at sign-in time, the plugin re-runs discovery to fill them in. This handles cases where you registered a provider before certain fields were required.
|
|
424
|
+
|
|
425
|
+
## Account linking and `trustEmailVerified`
|
|
426
|
+
|
|
427
|
+
When a user signs in via SSO with an email that already exists in your database, Better Auth's [account linking](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) determines what happens.
|
|
428
|
+
|
|
429
|
+
The `trustEmailVerified` option on this plugin controls whether the `email_verified` claim from the IdP is passed to Better Auth as the user's `emailVerified` field. If `true`, and the IdP says the email is verified, Better Auth may auto-link the account (depending on your `accountLinking` config).
|
|
430
|
+
|
|
431
|
+
**This option is deprecated.** The IdP's `email_verified` claim is a weak trust signal. Instead:
|
|
432
|
+
|
|
433
|
+
- Use `domainVerification: { enabled: true }` to verify that the SSO provider actually owns the domain.
|
|
434
|
+
- Configure Better Auth's `accountLinking.trustedProviders` to trust specific providers.
|
|
435
|
+
- Or set up `accountLinking.allowDifferentEmails` per your needs.
|
|
436
|
+
|
|
138
437
|
## Migration from `@better-auth/sso`
|
|
139
438
|
|
|
140
439
|
| `@better-auth/sso` | `@startino/better-auth-oidc` |
|
|
@@ -151,6 +450,41 @@ No `node:dns` required. Verification works on any runtime with `fetch`.
|
|
|
151
450
|
|
|
152
451
|
The database schema is the same minus the `samlConfig` column. If migrating from `@better-auth/sso`, you can drop the `samlConfig` column from your `ssoProvider` table, or leave it (it will be ignored).
|
|
153
452
|
|
|
453
|
+
## Exported utilities
|
|
454
|
+
|
|
455
|
+
The package exports OIDC discovery functions and types for advanced use cases:
|
|
456
|
+
|
|
457
|
+
### Discovery functions
|
|
458
|
+
|
|
459
|
+
| Export | Description |
|
|
460
|
+
|---|---|
|
|
461
|
+
| `discoverOIDCConfig(params)` | Main entry point. Fetches and hydrates OIDC config from an issuer URL. |
|
|
462
|
+
| `computeDiscoveryUrl(issuer)` | Returns `{issuer}/.well-known/openid-configuration`. |
|
|
463
|
+
| `fetchDiscoveryDocument(url, timeout?)` | Fetches and parses a discovery document. Default timeout: 10 seconds. |
|
|
464
|
+
| `validateDiscoveryUrl(url, isTrustedOrigin)` | Validates that a discovery URL is trusted. |
|
|
465
|
+
| `validateDiscoveryDocument(doc, issuer)` | Checks required fields and issuer match. |
|
|
466
|
+
| `normalizeDiscoveryUrls(doc, issuer, isTrustedOrigin)` | Validates and normalizes all endpoint URLs in a discovery document. |
|
|
467
|
+
| `normalizeUrl(name, endpoint, issuer)` | Normalizes a single URL, resolving relative paths against the issuer. |
|
|
468
|
+
| `selectTokenEndpointAuthMethod(doc, existing?)` | Picks the best token endpoint auth method from a discovery document. |
|
|
469
|
+
| `needsRuntimeDiscovery(config)` | Returns `true` if the config is missing `tokenEndpoint` or `jwksEndpoint`. |
|
|
470
|
+
| `mapDiscoveryErrorToAPIError(error)` | Converts a `DiscoveryError` to a Better Auth `APIError`. |
|
|
471
|
+
| `REQUIRED_DISCOVERY_FIELDS` | Array of required fields: `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`. |
|
|
472
|
+
|
|
473
|
+
### Types
|
|
474
|
+
|
|
475
|
+
| Export | Description |
|
|
476
|
+
|---|---|
|
|
477
|
+
| `OIDCConfig` | Full OIDC provider configuration object. |
|
|
478
|
+
| `SSOOptions` | Plugin configuration options. |
|
|
479
|
+
| `SSOProvider` | SSO provider record (conditional on `domainVerification`). |
|
|
480
|
+
| `OIDCDiscoveryDocument` | OpenID Connect Discovery 1.0 document shape. |
|
|
481
|
+
| `HydratedOIDCConfig` | Discovery-resolved config with all endpoints filled in. |
|
|
482
|
+
| `DiscoverOIDCConfigParams` | Parameters for `discoverOIDCConfig()`. |
|
|
483
|
+
| `DiscoveryErrorCode` | Union of discovery error codes. |
|
|
484
|
+
| `DiscoveryError` | Error class with `code` and `details`. |
|
|
485
|
+
| `RequiredDiscoveryField` | Union type of required discovery field names. |
|
|
486
|
+
| `OIDCSSOPlugin` | Plugin type for type inference. |
|
|
487
|
+
|
|
154
488
|
## Credits
|
|
155
489
|
|
|
156
490
|
This package is an OIDC-only extraction of [`@better-auth/sso`](https://github.com/better-auth/better-auth/tree/main/packages/sso) by [Bereket Engida](https://github.com/bereketa). All OIDC logic, discovery pipeline, organization linking, and provider management code originates from that package.
|