Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,25 @@ What's already there:

The prerequisite nobody expects:

- [ ] **The button can't just be added.** Social login is hidden on self-hosted
outright today — `{!selfHosted && <OAuthButtons/>}` at
`apps/dashboard/src/app/(auth)/login/page.tsx:246` and
`register/page.tsx:154` — because an operator with no `GITHUB_CLIENT_ID`
would get buttons that fail, and **nothing tells the dashboard which
providers are configured**. `OAuthButtons` hardcodes github+google. SSO
needs a server-advertised provider list (public, read-only, alongside the
`authMode`/`selfHosted` values `useAuthContext` already serves). That
endpoint doesn't exist yet and is the real first task.
- [x] **The button can't just be added** — DONE for the *primitive*, not for
SSO. `GET /health/env` now advertises `authProviders`, the social logins
the server actually has credentials for
(`apps/api/src/lib/auth-providers.ts` owns the predicate; `lib/auth.ts`
reads the SAME resolver to decide registration, so "a button is shown" and
"the provider is registered" cannot drift). Public, read-only, IDs only —
no client id, no secret, asserted by test. The dashboard carries it
through the auth layout's context, and `{!selfHosted && <OAuthButtons/>}`
is gone from both `login/page.tsx` and `register/page.tsx`:
`OAuthButtons` maps the advertised list and renders nothing (not even the
divider) when it is empty. Net effect — cloud unchanged, self-hosted
WITHOUT creds unchanged (nothing shown), self-hosted WITH creds now shows
buttons that work. `authMode === "none"` is untouched: the login page
returns its zero-auth redirect before the form renders.
What SSO still has to add here: an entry with a different `kind` (the
discriminator already exists and non-`"social"` entries are deliberately
NOT drawn as branded buttons), an operator-named label instead of a
hardcoded one, and the env gating for the issuer. Everything below is
still open.

Decisions to settle before coding:

Expand Down Expand Up @@ -211,9 +221,17 @@ What actually hardcodes GitHub — each is a decision, not a rename:
case; GitLab releases would be a third mode.
- [ ] **Dashboard speaks GitHub throughout**: `ServerGitHubConnect`,
`GithubPermissionModal`, `DeployCredentialModal`, the deploy wizard's
import step, `ResourcePicker`. Needs a server-advertised provider list —
the SAME missing primitive as the SSO item above (`OAuthButtons` hardcodes
github+google). Build that endpoint once and both features use it.
import step, `ResourcePicker`. Still all GitHub — this item is NOT done.
What IS done is the shared primitive it was blocked on: the public
read-only surface exists and has its first list on it — `GET /health/env`
→ `authProviders`, derived in `apps/api/src/lib/auth-providers.ts` (see
the Auth section above). That covers LOGIN providers only. Git providers
are a different capability record (can it list repos? push a deploy key?
serve a tarball?) and join the same endpoint as their own field rather
than being squeezed into `authProviders` — a git remote is not a login
button. The pattern to copy: one module owning the "is it configured?"
predicate, read by both the feature and the endpoint, IDs/capabilities
only and never credentials.
- [ ] **`gh` CLI as an ambient identity** (`sources/gh-cli-source.ts`,
`github.local-auth.ts:360` parses `oauth_token` under `github.com:` in
hosts.yml) has no equivalent worth matching. `glab` exists; decide
Expand Down
89 changes: 89 additions & 0 deletions apps/api/src/lib/auth-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* THE list of social/OAuth login providers this instance actually has
* credentials for — and the single place the condition "is <provider>
* configured?" is written.
*
* Why this exists: a provider is registered with Better Auth only when its
* client id AND secret are both present (`socialProviders` in lib/auth.ts), but
* nothing told the dashboard which ones made it in. So the dashboard guessed —
* `{!selfHosted && <OAuthButtons/>}` — and the guess was wrong in both
* directions: a self-hosted operator who HAD set GITHUB_CLIENT_ID/SECRET got no
* button at all, and any cloud deploy missing a pair would have rendered a
* button that dead-ends at Better Auth's "provider not found".
*
* The rule is written once, here, and read by two callers:
* - lib/auth.ts → whether to register the provider (needs the creds)
* - GET /health/env → whether to ADVERTISE it (never the creds)
* Keeping the predicate in one place is the same discipline as lib/auth-mode.ts:
* a second copy of a security-shaped condition drifts, and the drift is only
* visible as a login screen that disagrees with what the API enforces.
*
* SECRET DISCIPLINE: `configuredAuthProviders()` is served UNAUTHENTICATED. It
* returns provider IDs and nothing else — never a client id, never a secret, not
* even a redacted one. `socialProviderCredentials()` is the only export that
* touches credential values, it is server-internal, and its return value must
* never reach a response body.
*/

import { env } from "../config/env";

/** Every social provider the server knows how to register. */
export const SOCIAL_AUTH_PROVIDERS = ["github", "google"] as const;
export type SocialAuthProviderId = (typeof SOCIAL_AUTH_PROVIDERS)[number];

/**
* One advertised provider. Deliberately a RECORD, not a bare string: this is the
* public shape and it has to grow without breaking older dashboards.
*
* `kind` is the discriminator that growth will need. Today the only value is
* "social" (an env-configured OAuth provider whose button the dashboard already
* knows how to draw). A later SSO/OIDC provider is a different thing to render —
* one operator-named issuer rather than a branded button — and gets its own
* kind then. Nothing here implements SSO; the field only means a client can
* filter on it instead of assuming every entry is a github-shaped button.
*
* (The git-provider capability record the GitLab work needs is a SEPARATE
* surface — these are login providers. See TODO.md "Git providers".)
*/
export interface AdvertisedAuthProvider {
id: SocialAuthProviderId;
kind: "social";
}

/**
* The credential pair for a provider, or null when the operator hasn't
* configured it. Both halves are required: a client id without a secret cannot
* complete a redirect flow, so half-configured counts as not configured.
*
* SERVER-INTERNAL. Callers pass the result straight into Better Auth's provider
* config; it must not be serialised into any response.
*/
export function socialProviderCredentials(
id: SocialAuthProviderId,
): { clientId: string; clientSecret: string } | null {
const pair =
id === "github"
? { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }
: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET };

if (!pair.clientId || !pair.clientSecret) return null;
return { clientId: pair.clientId, clientSecret: pair.clientSecret };
}

/** True when this provider is registered with Better Auth on this instance. */
export function isSocialProviderConfigured(id: SocialAuthProviderId): boolean {
return socialProviderCredentials(id) !== null;
}

/**
* The public, read-only answer to "which login providers can I actually use?",
* in a stable order. Empty array = password login only, which is the correct
* answer for a default self-hosted instance and the reason the dashboard can
* render this list unconditionally instead of gating on `selfHosted`.
*/
export function configuredAuthProviders(): AdvertisedAuthProvider[] {
return SOCIAL_AUTH_PROVIDERS.filter(isSocialProviderConfigured).map((id) => ({
id,
kind: "social" as const,
}));
}
28 changes: 16 additions & 12 deletions apps/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
teardownBillingForOrg,
} from "../modules/billing/billing-org-cleanup";
import { provisionUser } from "./provision-user";
import { socialProviderCredentials } from "./auth-providers";
import { safeErrorMessage } from "@repo/core";

/**
Expand Down Expand Up @@ -115,6 +116,12 @@ function getSharedCookieDomain() {
const sharedCookieDomain = getSharedCookieDomain();
const useSessionCookieCache = getDriver() !== "pglite";

// Credential pairs, or null when the operator configured neither/half of one.
// Same resolver GET /health/env uses to advertise the provider list, so "a
// button is shown" and "the provider is registered" cannot disagree.
const githubOAuth = socialProviderCredentials("github");
const googleOAuth = socialProviderCredentials("google");

export const auth = betterAuth({
basePath: "/api/auth",
// Dynamic when served on a public URL — every absolute OAuth/auth URL is built
Expand Down Expand Up @@ -190,13 +197,17 @@ export const auth = betterAuth({
: undefined,
},

/* ---------- OAuth Providers ---------- */
/* ---------- OAuth Providers ----------
A provider is registered only when BOTH halves of its credential pair are
set. That condition now lives in lib/auth-providers.ts, because
GET /health/env advertises the same list to the dashboard so it can draw
exactly the buttons that will work — inlining the env check here again is
how the two would drift. */
socialProviders: {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
...(githubOAuth
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
...githubOAuth,
scope: ["read:user", "user:email"],
mapProfileToUser: (profile: any) => ({
name: profile.name || profile.login,
Expand All @@ -206,14 +217,7 @@ export const auth = betterAuth({
},
}
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(googleOAuth ? { google: googleOAuth } : {}),
},

/* ---------- Account Linking ---------- */
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/modules/health/health.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { APP_VERSION } from "../../lib/app-version";
import { getAuthMode } from "../../lib/auth-mode";
import { resolveProductMode } from "../../lib/product-mode";
import { resolveHostControlEnabled } from "../../lib/host-control";
import { configuredAuthProviders } from "../../lib/auth-providers";

/** Running server version (apps/api/package.json, via lib/app-version — the same
* value sent to the cloud on every call). Lets the dashboard tell a self-hosted
Expand Down Expand Up @@ -117,6 +118,17 @@ healthRoutes.get("/env", rateLimiterFor("default-anon"), async (c) => {
version: APP_VERSION,
authMode,
productMode,
// Which social logins this instance actually has credentials for, derived
// from the same predicate that decides whether Better Auth registers them
// (lib/auth-providers.ts). The dashboard renders exactly these buttons.
// Before this, it inferred "cloud ⇒ github+google, self-hosted ⇒ none",
// which hid working buttons from any operator who had configured a
// provider. IDs ONLY — this route is unauthenticated, so no client id and
// no secret may ever be added to these records. (LOGIN providers only. The
// git-provider capability record the GitLab work needs — #75, TODO.md "Git
// providers" — belongs on this same public surface as its own field, not
// folded in here: a git remote is not a login button.)
authProviders: configuredAuthProviders(),
teamMode,
migrationTargetUrl,
migrationInProgress,
Expand Down
105 changes: 105 additions & 0 deletions apps/api/test/lib/auth-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";

/**
* Which social logins does this instance actually have credentials for?
*
* The dashboard used to answer that question itself, with `!selfHosted` — a
* proxy that is wrong in both directions (a self-hosted operator WITH GitHub
* creds got no button; a cloud deploy missing a pair would have got a button
* that dead-ends). The answer now comes from the server, derived from the same
* predicate that decides whether Better Auth registers the provider at all.
*
* These tests pin the two halves that matter:
* 1. configured → advertised, not configured → absent (and half-configured
* counts as NOT configured — a client id with no secret cannot complete a
* redirect flow);
* 2. the advertised payload carries IDs and nothing else. This list is served
* unauthenticated, so a client id leaking into it is a real disclosure, and
* it would leak by the most ordinary of edits: returning the config object
* the registration path already builds.
*/

const env: Record<string, string | undefined> = {};

vi.mock("../../src/config/env", () => ({
get env() {
return env;
},
}));

async function load() {
vi.resetModules();
return import("../../src/lib/auth-providers");
}

afterEach(() => {
for (const key of Object.keys(env)) delete env[key];
});

describe("configuredAuthProviders", () => {
it("advertises nothing on a default self-hosted instance", async () => {
const { configuredAuthProviders } = await load();
// No creds in env — password login only. The dashboard renders no buttons,
// which is the same thing an operator sees today, just for a true reason.
expect(configuredAuthProviders()).toEqual([]);
});

it("advertises a provider once both halves of its credential pair are set", async () => {
env.GITHUB_CLIENT_ID = "Iv1.self-hosted-app";
env.GITHUB_CLIENT_SECRET = "shhh-github";

const { configuredAuthProviders } = await load();
// The improvement this whole change exists for: a self-hosted operator who
// DID configure GitHub now gets the button, where before it was hidden.
expect(configuredAuthProviders()).toEqual([{ id: "github", kind: "social" }]);
});

it("omits a half-configured provider — an id without a secret cannot sign anyone in", async () => {
env.GITHUB_CLIENT_ID = "Iv1.half-done";
env.GOOGLE_CLIENT_SECRET = "secret-without-an-id";

const { configuredAuthProviders } = await load();
expect(configuredAuthProviders()).toEqual([]);
});

it("advertises both, in a stable order, when both are configured (the cloud case)", async () => {
env.GITHUB_CLIENT_ID = "gh-id";
env.GITHUB_CLIENT_SECRET = "gh-secret";
env.GOOGLE_CLIENT_ID = "goog-id";
env.GOOGLE_CLIENT_SECRET = "goog-secret";

const { configuredAuthProviders } = await load();
expect(configuredAuthProviders().map((p) => p.id)).toEqual(["github", "google"]);
});

it("never puts a client id or secret in the advertised payload", async () => {
env.GITHUB_CLIENT_ID = "Iv1.SECRET-CLIENT-ID";
env.GITHUB_CLIENT_SECRET = "SECRET-CLIENT-SECRET";
env.GOOGLE_CLIENT_ID = "google-SECRET-CLIENT-ID";
env.GOOGLE_CLIENT_SECRET = "google-SECRET-CLIENT-SECRET";

const { configuredAuthProviders } = await load();
const serialized = JSON.stringify(configuredAuthProviders());

for (const value of Object.values(env)) {
expect(serialized).not.toContain(value);
}
// Belt and braces: the only keys are the two documented ones, so a future
// field can't be added without a test author looking at this assertion.
expect(Object.keys(configuredAuthProviders()[0]!).sort()).toEqual(["id", "kind"]);
});
});

describe("socialProviderCredentials", () => {
it("hands the registration path both halves when configured", async () => {
env.GOOGLE_CLIENT_ID = "goog-id";
env.GOOGLE_CLIENT_SECRET = "goog-secret";

const { socialProviderCredentials } = await load();
expect(socialProviderCredentials("google")).toEqual({
clientId: "goog-id",
clientSecret: "goog-secret",
});
expect(socialProviderCredentials("github")).toBeNull();
});
});
Loading