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/microsoft-single-tenant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

The Microsoft OAuth preset is now scoped to one tenant. `init --oauth` asks for the directory (tenant) id, refuses `common`, `organizations` and `consumers`, and writes tenant-specific endpoints. Without a tenant, Microsoft is scaffolded disabled with a placeholder, like a missing client id. Projects scaffolded earlier still point at `common`; replace it with your tenant id in the auth server's `OAUTH_PROVIDERS`.
4 changes: 3 additions & 1 deletion src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ With a template flag (e.g. --oauth, --react-oauth, --fastify):
• A template answers to both its id and its short alias, so --basic and
--react-vite select the same starter
• --oauth also prompts for OIDC providers (Google, GitHub, Microsoft,
GitLab) and wires the ones you configure into the auth server
GitLab) and wires the ones you configure into the auth server.
Microsoft asks for your directory (tenant) id and is never set up
to accept sign-ins from any tenant
• Run seamless templates list to see every id, alias, and flag

--profile <name>
Expand Down
54 changes: 53 additions & 1 deletion src/core/oauthProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildOAuthAuthEnv,
OAUTH_PROVIDER_CATALOG,
tenantProblem,
withLoginMethod,
type CollectedOAuthProvider,
} from "./oauthProviders.js";
Expand All @@ -10,10 +11,11 @@ function collected(
id: string,
clientId: string,
clientSecret: string,
tenant?: string,
): CollectedOAuthProvider {
const catalog = OAUTH_PROVIDER_CATALOG.find((p) => p.id === id);
if (!catalog) throw new Error(`unknown test provider ${id}`);
return { catalog, clientId, clientSecret };
return { catalog, clientId, clientSecret, ...(tenant !== undefined ? { tenant } : {}) };
}

describe("OAUTH_PROVIDER_CATALOG", () => {
Expand Down Expand Up @@ -93,6 +95,38 @@ describe("buildOAuthAuthEnv", () => {
expect(configs[0].clientId).toBe("client-only");
});

it("never scopes Microsoft to a multi-tenant endpoint", () => {
const microsoft = OAUTH_PROVIDER_CATALOG.find((p) => p.id === "microsoft")!;
for (const url of [microsoft.authorizationUrl, microsoft.tokenUrl]) {
expect(url).toContain("/{tenant}/");
expect(url).not.toMatch(/\/(common|organizations|consumers)\//);
}
});

it("fills the Microsoft endpoints with the tenant", () => {
const tenant = "2c0d53c2-a541-452b-b71b-54c7f15e5877";
const { env, pending } = buildOAuthAuthEnv([
collected("microsoft", "client-id", "secret", tenant),
]);

expect(pending).toEqual([]);
const [config] = JSON.parse(env.OAUTH_PROVIDERS);
expect(config.enabled).toBe(true);
expect(config.authorizationUrl).toBe(
`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
);
expect(config.tokenUrl).toBe(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`);
});

it("scaffolds Microsoft disabled with a placeholder when the tenant is missing", () => {
const { env, pending } = buildOAuthAuthEnv([collected("microsoft", "client-id", "secret")]);

expect(pending).toEqual(["Microsoft"]);
const [config] = JSON.parse(env.OAUTH_PROVIDERS);
expect(config.enabled).toBe(false);
expect(config.authorizationUrl).toContain("/REPLACE_WITH_MICROSOFT_TENANT_ID/");
});

it("handles multiple providers, replacing dashes in the secret env name", () => {
const { env, pending } = buildOAuthAuthEnv([
collected("google", "g-id", "g-secret"),
Expand All @@ -109,6 +143,24 @@ describe("buildOAuthAuthEnv", () => {
});
});

describe("tenantProblem", () => {
it("accepts a tenant id, a verified domain, or blank", () => {
expect(tenantProblem("2c0d53c2-a541-452b-b71b-54c7f15e5877")).toBeUndefined();
expect(tenantProblem("contoso.onmicrosoft.com")).toBeUndefined();
expect(tenantProblem(" ")).toBeUndefined();
});

it("refuses the multi-tenant aliases in any case", () => {
for (const alias of ["common", "Organizations", "CONSUMERS"]) {
expect(tenantProblem(alias)).toMatch(/any tenant/);
}
});

it("refuses something that is neither a GUID nor a domain", () => {
expect(tenantProblem("my tenant")).toMatch(/directory \(tenant\) id/);
});
});

describe("withLoginMethod", () => {
it("defaults to passkey,magic_link when current is undefined and appends the method", () => {
expect(withLoginMethod(undefined, "oauth")).toBe(
Expand Down
40 changes: 34 additions & 6 deletions src/core/oauthProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface OAuthProviderCatalogEntry {
pkce?: boolean;
// Provider-specific claim path overrides (defaults on the server are sub/email).
extra?: Record<string, unknown>;
// The endpoints contain `{tenant}`, filled from a tenant the user supplies.
tenanted?: boolean;
}

export const OAUTH_PROVIDER_CATALOG: OAuthProviderCatalogEntry[] = [
Expand All @@ -43,12 +45,16 @@ export const OAUTH_PROVIDER_CATALOG: OAuthProviderCatalogEntry[] = [
{
id: "microsoft",
label: "Microsoft",
// Tenant-scoped, never `common`, `organizations` or `consumers`. Those accept a
// sign-in from any Entra tenant, and every tenant's administrator controls the
// email its users present.
authorizationUrl:
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
tokenUrl: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
userInfoUrl: "https://graph.microsoft.com/oidc/userinfo",
scopes: ["openid", "email", "profile"],
pkce: true,
tenanted: true,
},
{
id: "gitlab",
Expand All @@ -66,6 +72,25 @@ export interface CollectedOAuthProvider {
catalog: OAuthProviderCatalogEntry;
clientId: string;
clientSecret: string;
/** For a tenanted provider: the directory (tenant) id or a verified domain. */
tenant?: string;
}

const MULTI_TENANT = new Set(["common", "organizations", "consumers"]);
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;

/** Why a tenant value cannot be used, or undefined when it can (blank is allowed). */
export function tenantProblem(value: string): string | undefined {
const tenant = value.trim();
if (!tenant) return undefined;
if (MULTI_TENANT.has(tenant.toLowerCase())) {
return `"${tenant}" accepts sign-ins from any tenant. Use your directory (tenant) id.`;
}
if (!GUID.test(tenant) && !DOMAIN.test(tenant)) {
return "Use the directory (tenant) id, a GUID, or a verified domain such as contoso.onmicrosoft.com.";
}
return undefined;
}

function secretEnvName(id: string): string {
Expand All @@ -83,10 +108,13 @@ export function buildOAuthAuthEnv(providers: CollectedOAuthProvider[]): {
const env: Record<string, string> = {};
const pending: string[] = [];

const configs = providers.map(({ catalog, clientId, clientSecret }) => {
const configs = providers.map(({ catalog, clientId, clientSecret, tenant = "" }) => {
const envName = secretEnvName(catalog.id);
const ready = clientId.length > 0 && clientSecret.length > 0;
const ready =
clientId.length > 0 && clientSecret.length > 0 && (!catalog.tenanted || tenant.length > 0);
if (!ready) pending.push(catalog.label);
const withTenant = (url: string) =>
url.replace("{tenant}", tenant || `REPLACE_WITH_${catalog.id.toUpperCase()}_TENANT_ID`);

env[envName] = clientSecret;

Expand All @@ -96,8 +124,8 @@ export function buildOAuthAuthEnv(providers: CollectedOAuthProvider[]): {
enabled: ready,
clientId: clientId || `REPLACE_WITH_${catalog.id.toUpperCase()}_CLIENT_ID`,
clientSecretEnv: envName,
authorizationUrl: catalog.authorizationUrl,
tokenUrl: catalog.tokenUrl,
authorizationUrl: withTenant(catalog.authorizationUrl),
tokenUrl: withTenant(catalog.tokenUrl),
userInfoUrl: catalog.userInfoUrl,
scopes: catalog.scopes,
redirectUri: REDIRECT_URI,
Expand Down
29 changes: 29 additions & 0 deletions src/prompts/oauthSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ describe("runOAuthSetupPrompts", () => {
expect(text).not.toHaveBeenCalled();
});

it("asks Microsoft for a tenant and validates it", async () => {
vi.mocked(multiselect).mockResolvedValue(["microsoft"] as never);
vi.mocked(text).mockImplementation(async (args: unknown) => {
const a = args as { message: string };
return a.message.includes("tenant") ? " contoso.onmicrosoft.com " : "ms-id";
});
vi.mocked(password).mockResolvedValue("ms-secret" as never);

const [result] = await runOAuthSetupPrompts();

expect(result.tenant).toBe("contoso.onmicrosoft.com");
const tenantPrompt = vi
.mocked(text)
.mock.calls.map(([args]) => args as { message: string; validate?: (v: string) => unknown })
.find((args) => args.message.includes("tenant"))!;
expect(tenantPrompt.validate!("common")).toMatch(/any tenant/);
});

it("does not ask providers without a tenant for one", async () => {
vi.mocked(multiselect).mockResolvedValue(["google"] as never);
vi.mocked(text).mockResolvedValue("g" as never);
vi.mocked(password).mockResolvedValue("s" as never);

const [result] = await runOAuthSetupPrompts();

expect(result).not.toHaveProperty("tenant");
expect(vi.mocked(text).mock.calls.some(([a]) => (a as { message: string }).message.includes("tenant"))).toBe(false);
});

it("collects trimmed credentials for each chosen provider", async () => {
vi.mocked(multiselect).mockResolvedValue(["google", "github"] as never);

Expand Down
12 changes: 12 additions & 0 deletions src/prompts/oauthSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { multiselect, password, text } from "@clack/prompts";
import { orCancel } from "../core/cancel.js";
import {
OAUTH_PROVIDER_CATALOG,
tenantProblem,
type CollectedOAuthProvider,
} from "../core/oauthProviders.js";

Expand Down Expand Up @@ -42,10 +43,21 @@ export async function runOAuthSetupPrompts(): Promise<CollectedOAuthProvider[]>
}),
) as string;

const tenant = catalog.tenanted
? (orCancel(
await text({
message: `${catalog.label} directory (tenant) ID`,
placeholder: "leave blank to configure later",
validate: (value) => tenantProblem(value ?? ""),
}),
) as string)
: undefined;

collected.push({
catalog,
clientId: (clientId ?? "").trim(),
clientSecret: (clientSecret ?? "").trim(),
...(tenant !== undefined ? { tenant: tenant.trim() } : {}),
});
}

Expand Down
Loading