fix(sso): repair SSO login bounce + migrate to @better-auth/oauth-provider (#307)

Resolves #306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the
SSO identity to an existing email/password admin instead of bouncing to /login
with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied
**Domain** field — cross-domain claims from a compromised IdP are refused.

Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider`
migration. **Operators using the OAuth-provider feature must rotate registered
client secrets after upgrade** (legacy plaintext → hashed storage; see the
0012 migration notes).

Verified end-to-end on the pr-307 image against a real Authentik instance:
SSO login lands on the dashboard, `accounts` table gets both `credential` and
`authentik` rows for the same user. See PR description for full details.
This commit is contained in:
ARUNAVO RAY
2026-06-02 11:40:54 +05:30
committed by GitHub
parent 695f0ff005
commit 66e3284898
22 changed files with 5467 additions and 188 deletions
+110
View File
@@ -188,6 +188,108 @@ function verify0011Migration(db: any) {
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
}
function seedPre0012Database(db: any) {
// The harness has already run migrations 0000-0011, so the legacy
// oidc-provider tables exist. Seed a registered client (with the legacy
// comma-separated redirect_urls format) plus the related token/consent rows
// to exercise the create/transform/drop paths in 0012.
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
}
function verify0012Migration(db: any) {
// Old provider tables are dropped.
for (const table of ["oauth_applications", "oauth_consent"]) {
const row = db
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
.get(table) as { name: string } | null;
assert(!row, `Expected ${table} table to be dropped after migration`);
}
// New provider tables exist.
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
const row = db
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
.get(table) as { name: string } | null;
assert(row, `Expected ${table} table to exist after migration`);
}
// The registered client is preserved and its redirect URIs converted from
// the legacy comma-separated string into a JSON string[].
const client = db
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
assert(client, "Expected migrated oauth_clients row for app1");
assert(client.client_id === "client-1", "Expected client_id to be preserved");
assert(client.name === "Example App", "Expected client name to be preserved");
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
const uris = JSON.parse(client.redirect_uris);
assert(
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
);
// The reshaped tables accept the new column layout.
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
}
function seedPre0013Database(db: any) {
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
// domainVerified and the organizations table still carries the inherited
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
// and the column-adds can be verified end-to-end.
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
}
function verify0013Migration(db: any) {
// New columns on sso_providers.
const ssoCols = db
.query("PRAGMA table_info(sso_providers)")
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
const saml = ssoCols.find((c) => c.name === "saml_config");
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
assert(saml, "Expected sso_providers.saml_config column to exist");
assert(saml.notnull === 0, "Expected saml_config to be nullable");
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
assert(
domainVerified.dflt_value === "true",
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
);
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
const ssoRow = db
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
// Organizations rebuild preserved the seeded row and dropped the inherited
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
const orgRow = db
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
.get() as { id: string; normalized_name: string } | null;
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
const orgCols = db
.query("PRAGMA table_info(organizations)")
.all() as Array<{ name: string; dflt_value: string | null }>;
const normName = orgCols.find((c) => c.name === "normalized_name");
assert(normName, "Expected organizations.normalized_name column to exist");
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
}
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
"0009_nervous_tyger_tiger": {
seed: seedPre0009Database,
@@ -201,6 +303,14 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
seed: seedPre0011Database,
verify: verify0011Migration,
},
"0012_oauth_provider_migration": {
seed: seedPre0012Database,
verify: verify0012Migration,
},
"0013_slim_galactus": {
seed: seedPre0013Database,
verify: verify0013Migration,
},
};
function lintMigrations(selectedMigrations: Migration[]) {