Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OIDC Authentication support #5

Merged
merged 4 commits into from
Dec 10, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/deno-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 57 additions & 48 deletions deno.lock

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

42 changes: 42 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions lib/http/asn-metadata-from-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function createMetadata(c: Context): Record<string, unknown> {
return {
client: "web",
path: c.req.path,
user: c.var.user,
denojson,
};
}
88 changes: 88 additions & 0 deletions lib/http/middleware/oidc.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
// 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[];
}
2 changes: 2 additions & 0 deletions lib/http/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion lib/http/routes/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions lib/http/routes/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -25,6 +26,7 @@ uiRoutes.get(
uiRoutes.get(
"/asn",
optionalQueryNamespaceValidator,
withUser,
async (c) => {
const namespace = c.req.valid("query").namespace;

Expand Down