Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/oauth-provider-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

When the OAuth template is selected, `seamless init` now prompts for OIDC providers (Google, GitHub, Microsoft, GitLab) and their client id/secret, then wires them into the scaffolded auth server: OAUTH_PROVIDERS config, a per-provider `*_CLIENT_SECRET` env var, the `oauth` login method, and the `http://localhost:5173/oauth/callback` redirect URI. Providers left without credentials are scaffolded disabled with a printed next-steps note. Apple is documented as manual (its client secret is a signed JWT and it has no userinfo endpoint). The scaffold now also generates `REFRESH_TOKEN_LOOKUP_SECRET`, `TOTP_SECRET_ENCRYPTION_KEY`, and `OAUTH_STATE_SECRET` (previously left empty, falling back to the service token), and adds a healthcheck to the generated web container.
52 changes: 48 additions & 4 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type RegistryEntry,
type TemplateManifest,
} from "../core/templates.js";
import { runOAuthSetupPrompts } from "../prompts/oauthSetup.js";
import type { CollectedOAuthProvider } from "../core/oauthProviders.js";

const AUTH_SERVER_URL = "http://localhost:5312";
const API_URL = "http://localhost:3000";
Expand Down Expand Up @@ -55,32 +57,44 @@ export async function runCLI(projectName?: string, aliases: string[] = []) {
return entry;
};

// Resolve the chosen templates, then place their files. Env wiring waits until the
// shared auth config (tokens, key id) exists below.
// Resolve the chosen templates (read manifests) before writing anything, so every
// prompt finishes before files are placed. Env wiring waits until the shared auth
// config (tokens, key id) exists below.
const selected: { entry: RegistryEntry; manifest: TemplateManifest; dir: string }[] =
[];
for (const id of [answers.webTemplateId, answers.apiTemplateId]) {
const entry = findEntry(id);
const manifest = await source.readManifest(entry);
assertCliSupports(manifest, entry.label);
const dir = path.join(root, manifest.targetDir);
selected.push({ entry, manifest, dir });
}

// Templates can opt into OAuth setup (manifest setup.oauth). Collect providers now,
// before scaffolding, so the auth server can be wired up with them below.
const webSelection = selected.find((s) => s.entry.kind === "web");
let oauthProviders: CollectedOAuthProvider[] = [];
if (webSelection?.manifest.setup?.oauth) {
oauthProviders = await runOAuthSetupPrompts();
}

for (const { entry, dir } of selected) {
console.log(`Adding ${entry.label} starter...`);
await source.copyInto(entry, dir);
selected.push({ entry, manifest, dir });
}

let sharedConfig: any = {};

if (answers.authMode === "local") {
sharedConfig = await generateAuthServer({ root }, "local");
sharedConfig = await generateAuthServer({ root }, "local", oauthProviders);
}

if (answers.useDocker) {
const dockerShared = await generateDockerCompose(root, {
authMode: answers.authMode,
adminMode: answers.adminMode,
includeAdmin: answers.includeAdmin,
oauth: oauthProviders,
});

if (answers.authMode === "docker") {
Expand Down Expand Up @@ -118,6 +132,36 @@ export async function runCLI(projectName?: string, aliases: string[] = []) {
authMode: answers.authMode,
useDocker: answers.useDocker,
});

printOAuthNextSteps(oauthProviders);
}

// Summarizes the OAuth wiring after scaffolding: which providers are ready and which
// still need credentials, plus the redirect URI to register with each provider.
function printOAuthNextSteps(providers: CollectedOAuthProvider[]) {
if (providers.length === 0) return;

const ready = providers
.filter((p) => p.clientId && p.clientSecret)
.map((p) => p.catalog.label);
const pending = providers
.filter((p) => !p.clientId || !p.clientSecret)
.map((p) => p.catalog.label);

console.log("\nOAuth providers");
if (ready.length) {
console.log(` Enabled: ${ready.join(", ")}`);
}
if (pending.length) {
console.log(` Needs credentials before use: ${pending.join(", ")}`);
console.log(
" Add the client id/secret in the auth environment (OAUTH_PROVIDERS and the",
);
console.log(' matching *_CLIENT_SECRET), then set that provider\'s "enabled" to true.');
}
console.log(
" Register this redirect URI with each provider: http://localhost:5173/oauth/callback",
);
}

export interface TemplatePreselect {
Expand Down
2 changes: 1 addition & 1 deletion src/core/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless-
// SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR.
export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates";

export const SEAMLESS_TEMPLATES_REF = "v0.2.0";
export const SEAMLESS_TEMPLATES_REF = "v0.2.1";
124 changes: 124 additions & 0 deletions src/core/oauthProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { generateSecret } from "./secrets.js";

// The web app's OAuth callback route. The scaffolded web container serves the app
// at :5173, and the react-oauth template redirects to /oauth/callback there.
const REDIRECT_URI = "http://localhost:5173/oauth/callback";

// A known OIDC/OAuth provider the CLI can wire up from just a client id + secret.
// The endpoints are the provider's well-known ones, so the user only supplies
// credentials. Apple is intentionally absent: its client secret is a short-lived
// signed JWT (Team id, Key id, .p8 key) and it has no userinfo endpoint, so it does
// not fit this "paste a client id and secret" flow and is documented as manual.
export interface OAuthProviderCatalogEntry {
id: string;
label: string;
authorizationUrl: string;
tokenUrl: string;
userInfoUrl: string;
scopes: string[];
pkce?: boolean;
// Provider-specific claim path overrides (defaults on the server are sub/email).
extra?: Record<string, unknown>;
}

export const OAUTH_PROVIDER_CATALOG: OAuthProviderCatalogEntry[] = [
{
id: "google",
label: "Google",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
scopes: ["openid", "email", "profile"],
pkce: true,
},
{
id: "github",
label: "GitHub",
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
scopes: ["read:user", "user:email"],
extra: { subjectJsonPath: "id", nameJsonPath: "name" },
},
{
id: "microsoft",
label: "Microsoft",
authorizationUrl:
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
userInfoUrl: "https://graph.microsoft.com/oidc/userinfo",
scopes: ["openid", "email", "profile"],
pkce: true,
},
{
id: "gitlab",
label: "GitLab",
authorizationUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
userInfoUrl: "https://gitlab.com/oauth/userinfo",
scopes: ["openid", "email", "profile"],
pkce: true,
},
];

// A provider the user chose plus the credentials they supplied (either may be blank).
export interface CollectedOAuthProvider {
catalog: OAuthProviderCatalogEntry;
clientId: string;
clientSecret: string;
}

function secretEnvName(id: string): string {
return `${id.toUpperCase().replace(/-/g, "_")}_CLIENT_SECRET`;
}

// Turns the chosen providers into the auth-server env: the OAUTH_PROVIDERS JSON, a
// per-provider client-secret env var, and OAUTH_STATE_SECRET. A provider missing
// either credential is scaffolded disabled (so the stack still boots) and reported
// in `pending` so the CLI can tell the user what to fill in.
export function buildOAuthAuthEnv(providers: CollectedOAuthProvider[]): {
env: Record<string, string>;
pending: string[];
} {
const env: Record<string, string> = {};
const pending: string[] = [];

const configs = providers.map(({ catalog, clientId, clientSecret }) => {
const envName = secretEnvName(catalog.id);
const ready = clientId.length > 0 && clientSecret.length > 0;
if (!ready) pending.push(catalog.label);

env[envName] = clientSecret;

return {
id: catalog.id,
name: catalog.label,
enabled: ready,
clientId: clientId || `REPLACE_WITH_${catalog.id.toUpperCase()}_CLIENT_ID`,
clientSecretEnv: envName,
authorizationUrl: catalog.authorizationUrl,
tokenUrl: catalog.tokenUrl,
userInfoUrl: catalog.userInfoUrl,
scopes: catalog.scopes,
redirectUri: REDIRECT_URI,
redirectUris: [REDIRECT_URI],
...(catalog.pkce ? { pkce: true } : {}),
...(catalog.extra ?? {}),
};
});

env.OAUTH_PROVIDERS = JSON.stringify(configs);
env.OAUTH_STATE_SECRET = generateSecret(32);

return { env, pending };
}

// Ensures a login method is present in a comma-separated LOGIN_METHODS value.
export function withLoginMethod(current: string | undefined, method: string): string {
const methods = (current ?? "passkey,magic_link")
.split(",")
.map((m) => m.trim())
.filter(Boolean);
if (!methods.includes(method)) methods.push(method);
return methods.join(",");
}
5 changes: 5 additions & 0 deletions src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export interface TemplateManifest {
project?: string;
flows?: string[];
};
// Optional interactive setup the CLI runs for this template. `oauth` triggers the
// OAuth provider prompts and wires the chosen providers into the auth server.
setup?: {
oauth?: boolean;
};
requires?: { cliMin?: string };
}

Expand Down
8 changes: 5 additions & 3 deletions src/generators/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,25 @@ import {
configureAuthLocalEnv,
envToDockerBlock,
} from "../docker/docker.js";
import type { CollectedOAuthProvider } from "../../core/oauthProviders.js";

const AUTH_REPO = "https://github.com/fells-code/seamless-auth-api";

export async function generateAuthServer(
context: any,
mode: "local" | "docker" | Symbol,
oauth: CollectedOAuthProvider[] = [],
) {
const { root } = context;

if (mode === "local") {
return await setupLocalAuth(root);
return await setupLocalAuth(root, oauth);
}

return await setupDockerAuth(root);
}

async function setupLocalAuth(root: string) {
async function setupLocalAuth(root: string, oauth: CollectedOAuthProvider[] = []) {
const authDir = path.join(root, "auth");

console.log("Cloning SeamlessAuth server...");
Expand All @@ -34,7 +36,7 @@ async function setupLocalAuth(root: string) {

console.log("Writing auth environment...");

const shared = await configureAuthLocalEnv(root);
const shared = await configureAuthLocalEnv(root, oauth);

console.log("Auth server ready in /auth");
return shared;
Expand Down
Loading
Loading