Set Up Auth Module #28

Closed
opened 2026-06-17 02:12:05 +00:00 by gostmeaper · 0 comments
gostmeaper commented 2026-06-17 02:12:05 +00:00 (Migrated from codeberg.org)

Overall Objective

To give a short rundown of how this works compared to the traditional better-auth system, rather than managing sessions ourselves with better-auth, the backend acts as a pure OAuth2 resource server trusting JWTs issued by ScottyLabs' Keycloak instance at idp.scottylabs.org and validating them locally against Keycloak's JWKS endpoint. The frontend will then handle the login flow and will attach the token to every API request as a Bearer header. The backend will then just validate the attached tokens.

After this issue:

  • Every protected route can access ctx.auth which contains the validated JWT payload (Andrew ID, subject, name, etc.)
  • Unauthenticated requests automatically get 401
  • The user table is lazily populated on first request via a GET /api/me route
  • Unlike with a better-auth based methodology, there is no session tables, no auth routes, no cookies

Suggested Approach

  1. Create secretspec.toml at the project root

We use the same secrets tooling as TartanVote. Copy and adapt:

[project]
name = "dorm-hub"
revision = "1.0"

[profiles.default]
DATABASE_URL = { description = "Postgres connection string", required = true }

[profiles.dev]

[profiles.prod]
OIDC_ISSUER      = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" }
APP_BASE_URL     = { description = "Public API base URL", default = "https://api.dorm-hub.scottylabs.org" }
FRONTEND_URL     = { description = "Public frontend URL", default = "https://dorm-hub.scottylabs.org" }
CORS_ORIGINS     = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub.scottylabs.org,https://api.dorm-hub.scottylabs.org" }
DATABASE_URL     = { description = "Postgres connection string", required = true }

[profiles.staging]
OIDC_ISSUER      = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" }
APP_BASE_URL     = { description = "Staging API base URL", default = "https://dorm-hub-api-staging.scottylabs.net" }
FRONTEND_URL     = { description = "Staging frontend URL", default = "https://dorm-hub-frontend-staging.scottylabs.net" }
CORS_ORIGINS     = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub-frontend-staging.scottylabs.net,https://dorm-hub-api-staging.scottylabs.net" }
DATABASE_URL     = { description = "Postgres connection string", required = true }

[profiles.preview]
OIDC_ISSUER      = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" }
APP_BASE_URL     = { description = "Preview API base URL", default = "https://dorm-hub-api-staging.scottylabs.net" }
FRONTEND_URL     = { description = "Preview frontend URL", default = "https://dorm-hub-frontend-staging.scottylabs.net" }
CORS_ORIGINS     = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub-frontend-staging.scottylabs.net,https://dorm-hub-api-staging.scottylabs.net" }
DATABASE_URL     = { description = "Postgres connection string", required = true }

Notice there's no OIDC_CLIENT_ID or OIDC_CLIENT_SECRET here — a resource server only needs the issuer URL to fetch the JWKS. It never exchanges credentials with Keycloak directly.

For local dev, run secretspec populate dev to write a .env. Add .env to .gitignore. Ask an org OpenBao admin to ensure DATABASE_URL and OIDC_ISSUER are available under the dorm-hub dev secrets.

  1. Add the dependency

run deno add elysia-oauth2-resource-server @elysiajs/swagger in the backend folder.

3. Create src/auth/middleware.ts

import { oauth2ResourceServer } from "elysia-oauth2-resource-server";

const issuer = Deno.env.get("OIDC_ISSUER")
  ?? "https://idp.scottylabs.org/realms/scottylabs";

// Keycloak's JWKS endpoint is always at this path under the issuer
const jwksUri = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`;

export const jwtAuth = oauth2ResourceServer({
  jwksUri,
  issuer,
  // audience: "dorm-hub",  // uncomment once the Keycloak client is configured
});
  1. Create src/auth/user.ts

Since there's no login hook, we create the user row on the first authenticated request instead:

import { db } from "../db/index.ts";
import { user } from "../db/schema.ts";
import { eq } from "drizzle-orm";

export async function getOrCreateUser(auth: {
  sub: string;
  preferred_username?: string;
  name?: string;
}) {
  const existing = await db.query.user.findFirst({
    where: eq(user.oidcSubject, auth.sub),
  });

  if (existing) return existing;

  const [created] = await db.insert(user).values({
    oidcSubject: auth.sub,
    andrewId: auth.preferred_username ?? auth.sub,
    name: auth.name ?? auth.preferred_username ?? "Unknown",
  }).returning();

  return created;
}
  1. Wire up in src/index.ts
import { Elysia } from "elysia";
import { swagger } from "@elysiajs/swagger";
import { jwtAuth } from "./auth/middleware.ts";
import { getOrCreateUser } from "./auth/user.ts";

const app = new Elysia()
  .use(swagger({ path: "/api/docs" }))
  .use(jwtAuth)  // validates Bearer token on every route; 401 if missing/invalid
  // GET /api/me — first-request user upsert + session check endpoint
  .get("/api/me", async ({ auth }) => {
    const dbUser = await getOrCreateUser(auth);
    return dbUser;
  })
  .listen(3000);

All other route files just use ctx.auth directly — no additional middleware needed since jwtAuth is mounted globally.

  1. Test locally
  • Run secretspec populate dev to get your .env
  • Start the server: deno task dev
  • Get a token from Keycloak (the frontend login flow, or grab one from the voting app's dev session for testing)
  • curl -H "Authorization: Bearer <token>" http://localhost:3000/api/me
  • Confirm a user row is created in the DB on first hit
  • Confirm a request without a token gets 401
  • Visit http://localhost:3000/api/docs to confirm Swagger loads
  1. Check preferred_username maps to Andrew ID

Inspect the JWT payload (paste the token into jwt.io) and verify preferred_username is the Andrew ID. If it's named differently in the ScottyLabs Keycloak realm, update getOrCreateUser accordingly.

Help & Resources

  • elysia-oauth2-resource-server (the library being used and made by anish)
  • auth.mjs + secretspec.toml from TartanVote (reference for the secrets pattern)
  • Keycloak JWKS URL for the ScottyLabs realm: https://idp.scottylabs.org/realms/scottylabs/protocol/openid-connect/certs. You can open this in a browser to confirm it resolves before running anything
  • jwt.io. Might want to paste a token here to inspect the claims and verify claim names
  • @elysiajs/swagger
### Overall Objective To give a short rundown of how this works compared to the traditional better-auth system, rather than managing sessions ourselves with better-auth, the backend acts as a pure **OAuth2 resource server** trusting JWTs issued by ScottyLabs' Keycloak instance at `idp.scottylabs.org` and validating them locally against Keycloak's JWKS endpoint. The frontend will then handle the login flow and will attach the token to every API request as a `Bearer` header. The backend will then just validate the attached tokens. After this issue: - Every protected route can access `ctx.auth` which contains the validated JWT payload (Andrew ID, subject, name, etc.) - Unauthenticated requests automatically get `401` - The `user` table is lazily populated on first request via a `GET /api/me` route - Unlike with a better-auth based methodology, there is no session tables, no auth routes, no cookies ### Suggested Approach 1. Create `secretspec.toml` at the project root We use the same secrets tooling as TartanVote. Copy and adapt: ```toml [project] name = "dorm-hub" revision = "1.0" [profiles.default] DATABASE_URL = { description = "Postgres connection string", required = true } [profiles.dev] [profiles.prod] OIDC_ISSUER = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" } APP_BASE_URL = { description = "Public API base URL", default = "https://api.dorm-hub.scottylabs.org" } FRONTEND_URL = { description = "Public frontend URL", default = "https://dorm-hub.scottylabs.org" } CORS_ORIGINS = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub.scottylabs.org,https://api.dorm-hub.scottylabs.org" } DATABASE_URL = { description = "Postgres connection string", required = true } [profiles.staging] OIDC_ISSUER = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" } APP_BASE_URL = { description = "Staging API base URL", default = "https://dorm-hub-api-staging.scottylabs.net" } FRONTEND_URL = { description = "Staging frontend URL", default = "https://dorm-hub-frontend-staging.scottylabs.net" } CORS_ORIGINS = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub-frontend-staging.scottylabs.net,https://dorm-hub-api-staging.scottylabs.net" } DATABASE_URL = { description = "Postgres connection string", required = true } [profiles.preview] OIDC_ISSUER = { description = "Keycloak issuer URL", default = "https://idp.scottylabs.org/realms/scottylabs" } APP_BASE_URL = { description = "Preview API base URL", default = "https://dorm-hub-api-staging.scottylabs.net" } FRONTEND_URL = { description = "Preview frontend URL", default = "https://dorm-hub-frontend-staging.scottylabs.net" } CORS_ORIGINS = { description = "Comma-separated allowed CORS origins", default = "https://dorm-hub-frontend-staging.scottylabs.net,https://dorm-hub-api-staging.scottylabs.net" } DATABASE_URL = { description = "Postgres connection string", required = true } ``` Notice there's no `OIDC_CLIENT_ID` or `OIDC_CLIENT_SECRET` here — a resource server only needs the issuer URL to fetch the JWKS. It never exchanges credentials with Keycloak directly. For local dev, run `secretspec populate dev` to write a `.env`. Add `.env` to `.gitignore`. Ask an org OpenBao admin to ensure `DATABASE_URL` and `OIDC_ISSUER` are available under the `dorm-hub` dev secrets. 2. Add the dependency run `deno add elysia-oauth2-resource-server @elysiajs/swagger` in the backend folder. **3. Create `src/auth/middleware.ts`** ```typescript import { oauth2ResourceServer } from "elysia-oauth2-resource-server"; const issuer = Deno.env.get("OIDC_ISSUER") ?? "https://idp.scottylabs.org/realms/scottylabs"; // Keycloak's JWKS endpoint is always at this path under the issuer const jwksUri = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`; export const jwtAuth = oauth2ResourceServer({ jwksUri, issuer, // audience: "dorm-hub", // uncomment once the Keycloak client is configured }); ``` 4. Create `src/auth/user.ts` Since there's no login hook, we create the `user` row on the first authenticated request instead: ```typescript import { db } from "../db/index.ts"; import { user } from "../db/schema.ts"; import { eq } from "drizzle-orm"; export async function getOrCreateUser(auth: { sub: string; preferred_username?: string; name?: string; }) { const existing = await db.query.user.findFirst({ where: eq(user.oidcSubject, auth.sub), }); if (existing) return existing; const [created] = await db.insert(user).values({ oidcSubject: auth.sub, andrewId: auth.preferred_username ?? auth.sub, name: auth.name ?? auth.preferred_username ?? "Unknown", }).returning(); return created; } ``` 5. Wire up in `src/index.ts` ```typescript import { Elysia } from "elysia"; import { swagger } from "@elysiajs/swagger"; import { jwtAuth } from "./auth/middleware.ts"; import { getOrCreateUser } from "./auth/user.ts"; const app = new Elysia() .use(swagger({ path: "/api/docs" })) .use(jwtAuth) // validates Bearer token on every route; 401 if missing/invalid // GET /api/me — first-request user upsert + session check endpoint .get("/api/me", async ({ auth }) => { const dbUser = await getOrCreateUser(auth); return dbUser; }) .listen(3000); ``` All other route files just use `ctx.auth` directly — no additional middleware needed since `jwtAuth` is mounted globally. 6. Test locally - Run `secretspec populate dev` to get your `.env` - Start the server: `deno task dev` - Get a token from Keycloak (the frontend login flow, or grab one from the voting app's dev session for testing) - `curl -H "Authorization: Bearer <token>" http://localhost:3000/api/me` - Confirm a `user` row is created in the DB on first hit - Confirm a request without a token gets `401` - Visit `http://localhost:3000/api/docs` to confirm Swagger loads 7. Check `preferred_username` maps to Andrew ID Inspect the JWT payload (paste the token into [jwt.io](https://jwt.io)) and verify `preferred_username` is the Andrew ID. If it's named differently in the ScottyLabs Keycloak realm, update `getOrCreateUser` accordingly. ### Help & Resources - [elysia-oauth2-resource-server](https://github.com/ap-1/elysia-oauth2-resource-server) (the library being used and made by anish) - [auth.mjs](https://codeberg.org/ScottyLabs/tartan-vote/src/branch/main/auth-service/auth.mjs) + [secretspec.toml](https://codeberg.org/ScottyLabs/tartan-vote/src/branch/main/secretspec.toml) from TartanVote (reference for the secrets pattern) - Keycloak JWKS URL for the ScottyLabs realm: `https://idp.scottylabs.org/realms/scottylabs/protocol/openid-connect/certs`. You can open this in a browser to confirm it resolves before running anything - [jwt.io](https://jwt.io). Might want to paste a token here to inspect the claims and verify claim names - [@elysiajs/swagger](https://elysiajs.com/plugins/swagger)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
ScottyLabs/housing#28
No description provided.