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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,20 @@
# GITHUB_CLIENT_SECRET= # GitHub OAuth app client secret
# GOOGLE_CLIENT_ID= # Google OAuth client ID
# GOOGLE_CLIENT_SECRET= # Google OAuth client secret
# AUTH0_DOMAIN= # Auth0 tenant domain, e.g. myteam.us.auth0.com
# AUTH0_CLIENT_ID= # Auth0 application client ID
# AUTH0_CLIENT_SECRET= # Auth0 application client secret
# AUTH0_ROLES_CLAIM=https://coderunner/roles # ID-token claim holding Auth0 role names
# AUTH0_USER_ROLE_NAME=user # Auth0 role that signs in as a student
# AUTH0_ADMIN_ROLE_NAME=admin # Auth0 role that signs in as an admin
#
# At least one OAuth provider must be configured for login to work.
# Register apps at your provider and set the callback URL:
# GitHub: ${BETTER_AUTH_URL}/api/auth/callback/github
# Google: ${BETTER_AUTH_URL}/api/auth/callback/google
# Auth0: ${BETTER_AUTH_URL}/api/auth/oauth2/callback/auth0
# Auth0 users skip the allowlist; their role comes from the roles claim
# (see docs/deploying/oauth-credentials.md).
#
# CODERUNNER_ADMIN_EMAIL= # Comma-separated emails that bootstrap admin
# # access. Each is added to the allowlist at
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ session. Guest sessions die with the classroom (checked in
container, and `ClassroomSweeper` deletes guests of ended/expired
classrooms. See `docs/decisions/043-classroom-join-codes.md`.

**Auth0 SSO (post-V2):** optional third login provider (`AUTH0_*` env) via
Better Auth's `genericOAuth`. Auth0 users skip the allowlist; their role comes
from an ID-token roles claim, re-read at every sign-in. Users with no role are
rejected in `mapProfileToUser` (after-hook throws are lost behind the callback
redirect). See `docs/decisions/044-auth0-sso.md`.

**Containerized control plane (post-V2):** the control plane ships as a Docker
image (`containers/control/Dockerfile` → `ghcr.io/mathewdunne/coderunner-control`)
and is deployed with docker compose (`docker-compose.yml` base +
Expand Down Expand Up @@ -145,7 +151,7 @@ arch-independent). See `docs/decisions/035-multi-arch-images-and-workflow-split.
## Key References

- `docs/` + `website/` — docs site content and Docusaurus config; published at `https://mathewdunne.github.io/CodeRunner/`; run `bun run docs:dev` to browse locally, `bun run docs:build` to build.
- `docs/decisions/` — all architecture decision logs (011–043 active; 001–010 archived under `docs/decisions/archive/`).
- `docs/decisions/` — all architecture decision logs (011–044 active; 001–010 archived under `docs/decisions/archive/`).
- Pinned AdvantageScope submodule: `vendor/AdvantageScope` at tag `v26.0.2`.

## Commands
Expand Down
86 changes: 82 additions & 4 deletions apps/control/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe("bootstrap admin (CODERUNNER_ADMIN_EMAIL)", () => {
);
});

test("startup seeding is idempotent, promotes existing students, leaves admins", async () => {
test("startup seeding is idempotent, promotes existing students, leaves admins and Auth0 users", async () => {
const root = await mkdtemp(join(tmpdir(), "frc-bootstrap-"));
try {
const catalogDir = await createCatalogDir(root);
Expand All @@ -294,15 +294,19 @@ describe("bootstrap admin (CODERUNNER_ADMIN_EMAIL)", () => {
sessionSecret: "test-session-secret",
baseUrl: "http://localhost:4000",
containerAutoStart: false,
adminEmails: ["coach@team.org", "boss@team.org"],
adminEmails: ["coach@team.org", "boss@team.org", "lead@team.org"],
};

// First startup: both admin emails are seeded into the allowlist.
const first = await createApp(baseOptions);
const afterFirst = JSON.parse(await readFile(allowlistPath, "utf8")) as {
emails: string[];
};
expect(afterFirst.emails).toEqual(["boss@team.org", "coach@team.org"]);
expect(afterFirst.emails).toEqual([
"boss@team.org",
"coach@team.org",
"lead@team.org",
]);

// Simulate accounts that already exist: a coach who signed in as a
// student before the env was set (with the mixed-case email the OAuth
Expand Down Expand Up @@ -334,6 +338,32 @@ describe("bootstrap admin (CODERUNNER_ADMIN_EMAIL)", () => {
"admin",
"boss",
);
// An Auth0 student holding an admin address. Auth0 sets the role at
// every sign-in (admin only for a verified address), so startup must
// not promote it: the address may be an unverified self-sign-up.
insertUser.run(
"userLeadCCCCCCCCCCCC",
"Lead",
"lead@team.org",
0,
null,
staleAdminTimestamp,
staleAdminTimestamp,
"student",
"lead",
);
first.storage.db
.query(
"INSERT INTO account (id, accountId, providerId, userId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
"acctLeadCCCCCCCCCCCC",
"auth0|lead",
"auth0",
"userLeadCCCCCCCCCCCC",
staleAdminTimestamp,
staleAdminTimestamp,
);
first.close();

// Second startup on the same data dir: idempotent allowlist, promotes the
Expand All @@ -343,7 +373,11 @@ describe("bootstrap admin (CODERUNNER_ADMIN_EMAIL)", () => {
const afterSecond = JSON.parse(
await readFile(allowlistPath, "utf8"),
) as { emails: string[] };
expect(afterSecond.emails).toEqual(["boss@team.org", "coach@team.org"]);
expect(afterSecond.emails).toEqual([
"boss@team.org",
"coach@team.org",
"lead@team.org",
]);

const coach = second.storage.db
.query("SELECT role, updatedAt FROM user WHERE id = ?")
Expand All @@ -357,6 +391,11 @@ describe("bootstrap admin (CODERUNNER_ADMIN_EMAIL)", () => {
expect(boss.role).toBe("admin");
// The already-admin row is not rewritten.
expect(boss.updatedAt).toBe(staleAdminTimestamp);

const lead = second.storage.db
.query("SELECT role FROM user WHERE email = ?")
.get("lead@team.org") as { role: string };
expect(lead.role).toBe("student");
} finally {
second.close();
}
Expand All @@ -381,10 +420,46 @@ describe("auth provider discovery", () => {
githubClientSecret: "github-client-secret",
googleClientId: "",
googleClientSecret: "",
auth0Domain: "",
auth0ClientId: "",
auth0ClientSecret: "",
},
);
});

test("lists auth0 only when its domain, client ID, and secret are all set", async () => {
const others = {
githubClientId: "",
githubClientSecret: "",
googleClientId: "",
googleClientSecret: "",
};
const providersFor = (options: Record<string, string>) =>
withApp(async (app) => {
const response = await app.fetch(
new Request("http://localhost/api/auth/providers"),
);
return ((await response.json()) as { providers: string[] }).providers;
}, options);

expect(
await providersFor({
...others,
auth0Domain: "tenant.auth0.test",
auth0ClientId: "id",
auth0ClientSecret: "secret",
}),
).toEqual(["auth0"]);
expect(
await providersFor({
...others,
auth0Domain: "tenant.auth0.test",
auth0ClientId: "id",
auth0ClientSecret: "",
}),
).toEqual([]);
});

test("returns an empty list when no OAuth providers are configured", async () => {
await withApp(
async (app) => {
Expand All @@ -399,6 +474,9 @@ describe("auth provider discovery", () => {
githubClientSecret: "",
googleClientId: "",
googleClientSecret: "",
auth0Domain: "",
auth0ClientId: "",
auth0ClientSecret: "",
},
);
});
Expand Down
Loading
Loading