From 242c6fb2485db4743d80351594d994b7b6b17548 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 1 Dec 2024 12:57:23 +0100 Subject: [PATCH 1/4] feat: :sparkles: Add OIDC Authentication OIDC Authentication will be enabled when the `OIDC_ISSUER` environment variable is set. --- README.md | 25 +++++- deno.json | 1 + deno.lock | 105 ++++++++++++++------------ example.env | 42 +++++++++++ lib/http/asn-metadata-from-context.ts | 1 + lib/http/middleware/oidc.ts | 88 +++++++++++++++++++++ lib/http/mod.ts | 1 + lib/http/routes/api.ts | 2 + lib/http/routes/svg.ts | 3 +- lib/http/routes/ui.tsx | 2 + 10 files changed, 220 insertions(+), 50 deletions(-) create mode 100644 lib/http/middleware/oidc.ts diff --git a/README.md b/README.md index 7879cfc..82b92eb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![JSR Scope](https://jsr.io/badges/@wuespace)](https://jsr.io/@wuespace) [![JSR](https://jsr.io/badges/@wuespace/asn-generator)](https://jsr.io/@wuespace/asn-generator) -[![JSR Score](https://jsr.io/badges/@wuespace/asn-generator/score)](https://jsr.io/@wuespace/asn-generator) +[![JSR Score](https://jsr.io/badges/@wuespace/asn-generator/score)](https://jsr.io/badges/@wuespace/asn-generator/score) [![Deno CI](https://github.com/wuespace/deno-asn-generator/actions/workflows/deno-ci.yml/badge.svg)](https://github.com/wuespace/deno-asn-generator/actions/workflows/deno-ci.yml) [![Docker](https://github.com/wuespace/deno-asn-generator/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/wuespace/deno-asn-generator/actions/workflows/docker-publish.yml) @@ -70,6 +70,29 @@ calculate the current namespace $n$ as follows: $$ n \in [r_{min}, r]; n = r_{min} + t \mod (r - r_{min}) $$ +## OpenID Connect / Authentication + +The system can optionally be configured to use OpenID Connect for authentication. +In this case, only authenticated users can generate ASNs. Read-only and lookup +routes will still be available to all users. + +OpenID Connect is enabled when the `OIDC_ISSUER` environment variable is set. + +To configure the system to use OpenID Connect, set the following environment variables: + +- `OIDC_ISSUER`: The issuer URL of the OpenID Connect provider. +- `OIDC_CLIENT_ID`: The client ID of the application. +- `OIDC_CLIENT_SECRET`: The client secret of the application. +- `OIDC_REDIRECT_URI`: The redirect URI of the application (`https://your-domain/oidc/callback`). +- `OIDC_SCOPES`: The scopes to request from the OpenID Connect provider. +- `OIDC_AUTH_SECRET`: A secret used to sign the session cookie. Must be at least 32 characters long. + +Optionally, you can set the following environment variables: + +- `OIDC_UID_CLAIM`: The claim in the ID token that contains the user's unique identifier. Defaults to `sub`. +- `OIDC_NAME_CLAIM`: The claim in the ID token that contains the user's name. Defaults to `name`. +- `OIDC_ROLES_CLAIM`: The claim in the ID token that contains the user's roles. Defaults to `groups`. While roles are currently not used by the generator, they may in the future be used for RBAC. + ## Ideas / Roadmap - [x] Configurability through environment variables diff --git a/deno.json b/deno.json index 0b28cdf..197f267 100644 --- a/deno.json +++ b/deno.json @@ -23,6 +23,7 @@ "$http/": "./lib/http/", "@collinhacks/zod": "npm:zod@^3.23.8", "@hono/hono": "jsr:@hono/hono@^4.5.11", + "@hono/oidc-auth": "npm:@hono/oidc-auth@^1.2.0", "@metafloor/bwip-js": "npm:bwip-js@^4.5.1", "@std/assert": "jsr:@std/assert@^1.0.4", "@std/cli": "jsr:@std/cli@^1.0.5", diff --git a/deno.lock b/deno.lock index 4c6cca6..00d597b 100644 --- a/deno.lock +++ b/deno.lock @@ -1,59 +1,68 @@ { - "version": "3", - "packages": { - "specifiers": { - "jsr:@hono/hono@^4.5.11": "jsr:@hono/hono@4.5.11", - "jsr:@std/assert@^1.0.4": "jsr:@std/assert@1.0.4", - "jsr:@std/cli@^1.0.5": "jsr:@std/cli@1.0.5", - "jsr:@std/dotenv@^0.225.2": "jsr:@std/dotenv@0.225.2", - "jsr:@std/internal@^1.0.3": "jsr:@std/internal@1.0.3", - "npm:@types/node": "npm:@types/node@18.16.19", - "npm:bwip-js@^4.5.1": "npm:bwip-js@4.5.1", - "npm:zod@^3.23.8": "npm:zod@3.23.8" - }, - "jsr": { - "@hono/hono@4.5.11": { - "integrity": "5bd6b1a3a503efb746fdcf0aae3ac536dd09229d372988bde5db0798ef64ae4f" - }, - "@std/assert@1.0.4": { - "integrity": "d4c767ea578e5bc09c15b6e503376003e5b2d1f4c0cdf08524a92101ff4d7b96", - "dependencies": [ - "jsr:@std/internal@^1.0.3" - ] - }, - "@std/cli@1.0.5": { - "integrity": "c93cce26ffd26f617c15a12874e1bfeabc90b1eee86017c9639093734c2bf587" - }, - "@std/dotenv@0.225.2": { - "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" - }, - "@std/internal@1.0.3": { - "integrity": "208e9b94a3d5649bd880e9ca38b885ab7651ab5b5303a56ed25de4755fb7b11e" - } - }, - "npm": { - "@types/node@18.16.19": { - "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", - "dependencies": {} - }, - "bwip-js@4.5.1": { - "integrity": "sha512-83yQCKiIftz5YonnsTh6wIkFoHHWl+B/XaGWD1UdRw7aB6XP9JtyYP9n8sRy3m5rzL+Ch/RUPnu28UW0RrPZUA==", - "dependencies": {} - }, - "zod@3.23.8": { - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dependencies": {} - } + "version": "4", + "specifiers": { + "jsr:@hono/hono@^4.5.11": "4.5.11", + "jsr:@std/assert@^1.0.4": "1.0.4", + "jsr:@std/cli@^1.0.5": "1.0.5", + "jsr:@std/dotenv@~0.225.2": "0.225.2", + "jsr:@std/internal@^1.0.3": "1.0.3", + "npm:@hono/oidc-auth@^1.2.0": "1.2.0_hono@4.6.9", + "npm:@types/node@*": "18.16.19", + "npm:bwip-js@^4.5.1": "4.5.1", + "npm:zod@^3.23.8": "3.23.8" + }, + "jsr": { + "@hono/hono@4.5.11": { + "integrity": "5bd6b1a3a503efb746fdcf0aae3ac536dd09229d372988bde5db0798ef64ae4f" + }, + "@std/assert@1.0.4": { + "integrity": "d4c767ea578e5bc09c15b6e503376003e5b2d1f4c0cdf08524a92101ff4d7b96", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/cli@1.0.5": { + "integrity": "c93cce26ffd26f617c15a12874e1bfeabc90b1eee86017c9639093734c2bf587" + }, + "@std/dotenv@0.225.2": { + "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" + }, + "@std/internal@1.0.3": { + "integrity": "208e9b94a3d5649bd880e9ca38b885ab7651ab5b5303a56ed25de4755fb7b11e" + } + }, + "npm": { + "@hono/oidc-auth@1.2.0_hono@4.6.9": { + "integrity": "sha512-H/6LU86o7hJW5c/m9apxk0+U1JMlGR08AOZ45rlp1q7Pj+4f1mVJBPAILUYOhjZC0G5tOOSSgeCfs1o4IBQlpA==", + "dependencies": [ + "hono", + "oauth4webapi" + ] + }, + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==" + }, + "bwip-js@4.5.1": { + "integrity": "sha512-83yQCKiIftz5YonnsTh6wIkFoHHWl+B/XaGWD1UdRw7aB6XP9JtyYP9n8sRy3m5rzL+Ch/RUPnu28UW0RrPZUA==" + }, + "hono@4.6.9": { + "integrity": "sha512-p/pN5yZLuZaHzyAOT2nw2/Ud6HhJHYmDNGH6Ck1OWBhPMVeM1r74jbCRwNi0gyFRjjbsGgoHbOyj7mT1PDNbTw==" + }, + "oauth4webapi@2.17.0": { + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==" + }, + "zod@3.23.8": { + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" } }, - "remote": {}, "workspace": { "dependencies": [ "jsr:@hono/hono@^4.5.11", "jsr:@std/assert@^1.0.4", "jsr:@std/cli@^1.0.5", - "jsr:@std/datetime@^0.225.2", - "jsr:@std/dotenv@^0.225.2", + "jsr:@std/datetime@~0.225.2", + "jsr:@std/dotenv@~0.225.2", + "npm:@hono/oidc-auth@^1.2.0", "npm:bwip-js@^4.5.1", "npm:zod@^3.23.8" ] diff --git a/example.env b/example.env index 7e7315e..442c03f 100644 --- a/example.env +++ b/example.env @@ -103,3 +103,45 @@ ADDITIONAL_MANAGED_NAMESPACES="<700 NDA-Covered Documents (Generic)><800 Persona # This is required if DB_FILE_NAME is a URL. # The token must be set in the environment variable DENO_KV_ACCESS_TOKEN as per Deno's requirements. # DENO_KV_ACCESS_TOKEN=XXX + +# OIDC (OpenID Connect) Configuration +# +# The URL of the OIDC provider's authorization server. +# This is where your application will redirect users to authenticate. +# If not set, OIDC authentication will be disabled. +# OIDC_ISSUER=https://logto.example.com/oidc +# +# Secret key used for signing and verifying tokens. +# Must be at least 32 characters long for security purposes. +# OIDC_AUTH_SECRET=RANDOM_SECRET_WITH_MIN_32_CHARS_CHANGE_ME_IMMEDIATELY_UPON_COPYING +# +# Client ID provided by your OIDC provider. +# Replace "XXX" with your actual client ID. +# OIDC_CLIENT_ID="XXX" +# +# Client Secret provided by your OIDC provider. +# Replace "XXX" with your actual client secret. +# OIDC_CLIENT_SECRET="XXX" +# +# The URL to which the OIDC provider will redirect users after authentication. +# This should match the redirect URI registered with your OIDC provider. +# OIDC_REDIRECT_URI=http://localhost:41319/oidc/callback +# +# Scopes requested from the OIDC provider. +# These determine the information returned in the ID token. +# OIDC_SCOPES="openid profile roles" +# +# Optional: Uncomment and set if your application uses roles. +# The claim in the ID token that contains the user's roles. +# Defaults to "roles". +# OIDC_ROLES_CLAIM=The claim in the ID token that contains the user's roles. +# +# Optional: Uncomment and set if your application uses a custom UID claim. +# The claim in the ID token that contains the user's unique identifier. +# Defaults to "sub". +# OIDC_UID_CLAIM=The claim in the ID token that contains the user's unique identifier. +# +# Optional: Uncomment and set if your application uses a custom name claim. +# The claim in the ID token that contains the user's name. +# Defaults to "name". +# OIDC_NAME_CLAIM=The claim in the ID token that contains the user's name. diff --git a/lib/http/asn-metadata-from-context.ts b/lib/http/asn-metadata-from-context.ts index 13bb3b6..9029e16 100644 --- a/lib/http/asn-metadata-from-context.ts +++ b/lib/http/asn-metadata-from-context.ts @@ -12,6 +12,7 @@ export function createMetadata(c: Context): Record { return { client: "web", path: c.req.path, + user: c.var.user, denojson, }; } diff --git a/lib/http/middleware/oidc.ts b/lib/http/middleware/oidc.ts new file mode 100644 index 0000000..64b8331 --- /dev/null +++ b/lib/http/middleware/oidc.ts @@ -0,0 +1,88 @@ +// deno-lint-ignore-file no-explicit-any +// We need any to resolve incompatibilities between node and deno hono types +import { createMiddleware } from "@hono/hono/factory"; +import { + getAuth, + type IDToken, + type OidcAuth, + oidcAuthMiddleware, + processOAuthCallback, + type TokenEndpointResponses, +} from "@hono/oidc-auth"; +import { z } from "@collinhacks/zod"; +import { HTTPException } from "@hono/hono/http-exception"; + +export const withUser = createMiddleware<{ + Variables: { + readonly user?: User; + readonly oidcClaimsHook: typeof oidcClaimsHook; + }; +}>(async (c, next) => { + if (!Deno.env.has("OIDC_ISSUER")) { + return next(); + } + + if (c.get("user")) { + // apperently, we've already run this middleware + return next(); + } + + if (c.req.path === "/oidc/callback") { + c.set("oidcClaimsHook", oidcClaimsHook); + // "as any" is needed because of incompatible types between node and deno + return processOAuthCallback(c as any); + } + + return await oidcAuthMiddleware()(c as any, async () => { + c.set("oidcClaimsHook", oidcClaimsHook); // re-set in case the token gets refreshed + // "as any" is needed because of incompatible types between node and deno + // "as User" is safe since we know "oidcClaimsHook" will return a User + const authorizedUser = await getAuth(c as any) as unknown as User; + c.set("user", authorizedUser); + return next(); + }); +}); + +function oidcClaimsHook( + orig: OidcAuth | undefined, + claims: IDToken | undefined, + _response: TokenEndpointResponses, +): Promise { + // Define oidcConfig with the necessary properties + const oidcConfig = { + OIDC_UID_CLAIM: Deno.env.get("OIDC_UID_CLAIM") ?? "sub", + OIDC_NAME_CLAIM: Deno.env.get("OIDC_NAME_CLAIM") ?? "name", + OIDC_ROLES_CLAIM: Deno.env.get("OIDC_ROLES_CLAIM") ?? "groups", + }; + + const { data: userClaims, error } = z.object({ + id: z.string().min(1), + name: z.string().default("Anonymous User"), + roles: z.union([z.string(), z.array(z.string())]).transform((roles) => + // if roles is a string, split it by space + Array.isArray(roles) ? roles : roles.split(" ") + ), + }).safeParse({ + id: claims?.[oidcConfig.OIDC_UID_CLAIM] ?? + orig?.[oidcConfig.OIDC_UID_CLAIM], + name: claims?.[oidcConfig.OIDC_NAME_CLAIM] ?? + orig?.[oidcConfig.OIDC_NAME_CLAIM], + roles: claims?.[oidcConfig.OIDC_ROLES_CLAIM] ?? + orig?.[oidcConfig.OIDC_ROLES_CLAIM] ?? [], + }); + + if (error) { + throw new HTTPException(500, { + message: "Failed to parse user claims", + cause: error, + }); + } + + return Promise.resolve(userClaims); +} + +export interface User { + id: string; + name: string; + roles: string[]; +} diff --git a/lib/http/mod.ts b/lib/http/mod.ts index d6f73b8..6180537 100644 --- a/lib/http/mod.ts +++ b/lib/http/mod.ts @@ -15,6 +15,7 @@ import { svgRoutes } from "$http/routes/svg.ts"; import { uiRoutes } from "$http/routes/ui.tsx"; import { apiRoutes } from "$http/routes/api.ts"; import { lookupRoutes } from "$http/routes/lookup.ts"; +import { withUser } from "$http/middleware/oidc.ts"; export * from "$http/lookup-url.ts"; export * from "$http/barcode-svg.ts"; diff --git a/lib/http/routes/api.ts b/lib/http/routes/api.ts index c06c872..cadadac 100644 --- a/lib/http/routes/api.ts +++ b/lib/http/routes/api.ts @@ -4,12 +4,14 @@ import { generateASN } from "$common/mod.ts"; import { createMetadata } from "$http/mod.ts"; import { optionalQueryNamespaceValidator } from "$http/validators/query/optional-namespace.ts"; +import { withUser } from "$http/middleware/oidc.ts"; export const apiRoutes = new Hono(); apiRoutes.get( "/asn", optionalQueryNamespaceValidator, + withUser, async (c) => c.json( await generateASN( diff --git a/lib/http/routes/svg.ts b/lib/http/routes/svg.ts index af1bd46..4237300 100644 --- a/lib/http/routes/svg.ts +++ b/lib/http/routes/svg.ts @@ -4,10 +4,11 @@ import { createBarcodeSVG } from "$http/barcode-svg.ts"; import { createMetadata } from "../mod.ts"; import { paramValidASNValidator } from "../validators/param/valid-asn.ts"; import { optionalQueryNamespaceValidator } from "$http/validators/query/optional-namespace.ts"; +import { withUser } from "$http/middleware/oidc.ts"; export const svgRoutes = new Hono(); -svgRoutes.get("/", optionalQueryNamespaceValidator, async (c) => { +svgRoutes.get("/", optionalQueryNamespaceValidator, withUser, async (c) => { const barcode = createBarcodeSVG( (await generateASN(createMetadata(c), c.req.valid("query").namespace)).asn, !!c.req.query("embed"), diff --git a/lib/http/routes/ui.tsx b/lib/http/routes/ui.tsx index 88e38d5..26565b3 100644 --- a/lib/http/routes/ui.tsx +++ b/lib/http/routes/ui.tsx @@ -9,6 +9,7 @@ import { Wrapper } from "$http/ui/wrapper.tsx"; import { IndexPage } from "$http/ui/index.tsx"; import { ASNPage } from "$http/ui/asn.tsx"; import { optionalQueryNamespaceValidator } from "../validators/query/optional-namespace.ts"; +import { withUser } from "$http/middleware/oidc.ts"; export const uiRoutes = new Hono(); @@ -25,6 +26,7 @@ uiRoutes.get( uiRoutes.get( "/asn", optionalQueryNamespaceValidator, + withUser, async (c) => { const namespace = c.req.valid("query").namespace; From 96ad6cfadda2cc87fce5fda8db89b3a984981f64 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 1 Dec 2024 12:58:22 +0100 Subject: [PATCH 2/4] style: :rotating_light: Remove unnecessary import --- lib/http/mod.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/http/mod.ts b/lib/http/mod.ts index 6180537..d6f73b8 100644 --- a/lib/http/mod.ts +++ b/lib/http/mod.ts @@ -15,7 +15,6 @@ import { svgRoutes } from "$http/routes/svg.ts"; import { uiRoutes } from "$http/routes/ui.tsx"; import { apiRoutes } from "$http/routes/api.ts"; import { lookupRoutes } from "$http/routes/lookup.ts"; -import { withUser } from "$http/middleware/oidc.ts"; export * from "$http/lookup-url.ts"; export * from "$http/barcode-svg.ts"; From dd408810dedbc63fb97172a23d9ba277f4dab6f5 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 1 Dec 2024 13:00:44 +0100 Subject: [PATCH 3/4] chore: :construction_worker: Upgrade to Deno v2 --- .github/workflows/deno-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deno-ci.yml b/.github/workflows/deno-ci.yml index e786481..d2a73cb 100644 --- a/.github/workflows/deno-ci.yml +++ b/.github/workflows/deno-ci.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.x + deno-version: v2.x # Uncomment this step to verify the use of 'deno fmt' on each commit. # - name: Verify formatting From c56703ea6da1c440ee9651daf247057d872ba702 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 1 Dec 2024 21:20:24 +0100 Subject: [PATCH 4/4] feat: :wrench: Upgrade Deno in `Dockerfile` to v2.1.2 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d5eda46..bac1593 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:1.45.5 +FROM denoland/deno:2.1.2 LABEL org.label-schema.name="deno-asn-generator" LABEL org.opencontainers.image.description="A Deno based system for generating / managing ASNs for documentat management systems in a collaborative environment."