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
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yc-software/qm",
"version": "0.1.6",
"version": "0.1.7",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
6 changes: 5 additions & 1 deletion cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,10 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
{
name: "AUTH_ALLOWED_EMAILS",
service: "auth",
required: { when: { kind: "env-absent", service: "auth", name: "AUTH_ALLOWED_EMAIL_DOMAIN" } },
required: {
when: { kind: "env-absent", service: "auth", name: "AUTH_ALLOWED_EMAIL_DOMAIN" },
optionalOtherwise: true,
},
description: "Comma-separated email addresses allowed to sign in through the built-in broker.",
},
{
Expand All @@ -331,6 +334,7 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
{ kind: "env-absent", service: "auth", name: "AUTH_ALLOWED_EMAIL_DOMAIN" },
],
},
optionalOtherwise: true,
},
description: "Email addresses allowed to sign in; the portal enforces the same list the broker does.",
},
Expand Down
5 changes: 4 additions & 1 deletion cli/test/auth-broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ test("the broker's generated secrets reach both sides under the right names", ()
assert.ok(!names.has("OIDC_CLIENT_SECRET"), "the operator is never asked for an OIDC client secret in broker mode");
assert.ok(!names.has("OIDC_CLIENT_ID"));
assert.ok(!names.has("PORTAL_EXPECTED_TEAM_ID"));
assert.ok(!names.has("AUTH_ALLOWED_EMAILS"), "a configured domain removes the per-address allowlist requirement");
const allowed = secrets.find((secret) => secret.name === "AUTH_ALLOWED_EMAILS")!;
assert.equal(allowed.required, false);
assert.deepEqual(runtimeSecretNames("auth", allowed), ["AUTH_ALLOWED_EMAILS"]);
assert.deepEqual(runtimeSecretNames("portal", allowed), ["OIDC_ALLOWED_EMAILS"]);
assert.ok(names.has("RESEND_API_KEY"));
assert.ok(!names.has("SMTP_HOST"), "only the configured transport's credentials are collected");
assert.ok(secretsForService(config, "auth").some((secret) => secret.name === "CORE_SIGNING_SECRET"));
Expand Down
9 changes: 3 additions & 6 deletions plugins/portal/src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,19 +135,16 @@ export function resolvePrincipal(
const verified = args.userinfo.email_verified;
if (verified !== true && verified !== "true") throw new Error("email is not verified by the identity provider");
const email = rawEmail.trim().toLowerCase();
if (
rule.allowedEmails?.length &&
!rule.allowedEmails.map((allowed) => allowed.trim().toLowerCase()).includes(email)
) {
throw new Error("account is not on the permitted email list");
}
if (rule.allowedEmails?.map((allowed) => allowed.trim().toLowerCase()).includes(email)) return email;
if (rule.allowedEmailDomain) {
const domain = rule.allowedEmailDomain.toLowerCase();
if (!email.endsWith(`@${domain}`)) throw new Error("account is outside the permitted domain");
const hd = args.userinfo.hd ?? args.claims.hd;
if (typeof hd === "string" && hd.toLowerCase() !== domain)
throw new Error("account is outside the permitted domain");
return email;
}
if (rule.allowedEmails?.length) throw new Error("account is not on the permitted email list");
return email;
}

Expand Down
16 changes: 16 additions & 0 deletions plugins/portal/test/oidc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,19 @@ test("resolvePrincipal allowedEmails permits only the seeded verified addresses"
/permitted email list/,
);
});

test("resolvePrincipal permits an email matching either the list or domain", () => {
const rule = {
claim: "email" as const,
allowedEmails: ["admin@gmail.com"],
allowedEmailDomain: "example.com",
};
for (const email of ["admin@gmail.com", "member@example.com"]) {
assert.equal(resolvePrincipal(rule, { sub: "g", claims: {}, userinfo: { email, email_verified: true } }), email);
}
assert.throws(
() =>
resolvePrincipal(rule, { sub: "g", claims: {}, userinfo: { email: "other@gmail.com", email_verified: true } }),
/permitted domain/,
);
});