Skip to content

Commit

Permalink
Better OIDC Support (#175)
Browse files Browse the repository at this point in the history
Now the identity service requires a client secret, we need to keep it
secret by passing it in as a private environment variable.  This means
all authorization functionality needs to be moved server-side rather
than exposing it in an SPA.
  • Loading branch information
spjmurray authored Feb 19, 2025
1 parent 494d5f2 commit 7d61b57
Show file tree
Hide file tree
Showing 13 changed files with 305 additions and 178 deletions.
4 changes: 2 additions & 2 deletions charts/ui/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ description: A Helm chart for deploying Unikorn UI

type: application

version: v0.3.9-rc1
appVersion: v0.3.9-rc1
version: v0.3.9-rc2
appVersion: v0.3.9-rc2

icon: https://assets.unikorn-cloud.org/assets/images/logos/dark-on-light/icon.png

Expand Down
21 changes: 18 additions & 3 deletions charts/ui/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ spec:
env:
- name: PUBLIC_APPLICATION_VERSION
value: {{ .Chart.Version }}
- name: PUBLIC_OAUTH2_ISSUER
- name: PUBLIC_IDENTITY_HOST
value: https://{{ include "unikorn.identity.host" . }}
- name: PUBLIC_REGION_HOST
value: https://{{ include "unikorn.region.host" . }}
Expand All @@ -40,16 +40,31 @@ spec:
value: https://{{ include "unikorn.application.host" . }}
- name: PUBLIC_COMPUTE_HOST
value: https://{{ include "unikorn.compute.host" . }}
- name: PUBLIC_OAUTH2_CLIENT_ID
- name: OIDC_CLIENT_ID
{{- if .Values.oauth2.clientName }}
value: {{ include "resource.id" .Values.oauth2.clientName }}
{{- else }}
value: {{ .Values.oauth2.clientID }}
{{- end }}
- name: PUBLIC_OAUTH2_CLIENT_SECRET
- name: OIDC_CLIENT_SECRET
value: {{ .Values.oauth2.clientSecret }}
{{- if .Values.tls.private }}
- name: NODE_EXTRA_CA_CERTS
value: /var/run/secrets/unikorn-cloud.org/ca.crt
{{- end }}
securityContext:
readOnlyRootFilesystem: true
{{- if .Values.tls.private }}
volumeMounts:
- name: unikorn-ui-ingress-tls
mountPath: /var/run/secrets/unikorn-cloud.org
{{- end }}
serviceAccountName: unikorn-ui
securityContext:
runAsNonRoot: true
{{- if .Values.tls.private }}
volumes:
- name: unikorn-ui-ingress-tls
secret:
secretName: unikorn-ui-ingress-tls
{{- end }}
5 changes: 5 additions & 0 deletions charts/ui/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ oauth2:
# and then hashed into a predictlable ID. Overrides the ID.
# clientName: foo

tls:
# When marked as private, this will mount the ingress secret into the
# pod and expose the CA certificate to Node.
private: false

ingress:
# Sets the ingress class to use.
class: ~
Expand Down
38 changes: 12 additions & 26 deletions src/lib/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,40 +76,26 @@ async function accessToken(tokens: InternalToken, fetchImpl?: typeof fetch): Pro
// we are repeating the operation, be nice if we could handle this
// somehow.
if (new Date(Date.now()).toJSON() > tokens.expiry) {
const discovery = await OIDC.discovery(fetchImpl || fetch);

const form = new URLSearchParams({
grant_type: 'refresh_token',
client_id: OIDC.clientID,
client_secret: OIDC.clientSecret,
const query = new URLSearchParams({
refresh_token: tokens.refresh_token
});

const options = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: form.toString()
};

const response = await fetch(discovery.token_endpoint, options);

const result = await response.json();
const target = new URL(`${window.location.protocol}//${window.location.host}/oauth2/refresh`);
target.search = query.toString();

const response = await fetch(target.toString());
if (!response.ok) {
const err = result as Identity.ModelError;

if (err.error == Identity.ModelErrorErrorEnum.InvalidGrant) {
removeCredentials();
}
removeCredentials();
return '';
}

// This will utimately turn into a 400 due to missing headers.
// TODO: Not the best way to handle things to be honest.
const tokenRaw = response.headers.get('X-Unikorn-Token');
if (!tokenRaw) {
console.log('token header missing');
return '';
}

const new_token = result as InternalToken;
const new_token = JSON.parse(tokenRaw) as InternalToken;

// Set the expiry time so we know when to perform a rotation.
// Add a little wiggle room in there to account for any latency.
Expand Down Expand Up @@ -165,7 +151,7 @@ export function application(

export function identity(tokens: InternalToken, fetchImpl?: typeof fetch): Identity.DefaultApi {
const config = new Identity.Configuration({
basePath: env.PUBLIC_OAUTH2_ISSUER,
basePath: env.PUBLIC_IDENTITY_HOST,
accessToken: async () => accessToken(tokens, fetchImpl),
middleware: [authenticationMiddleware(), traceContextMiddleware()],
fetchApi: fetchImpl
Expand Down
4 changes: 1 addition & 3 deletions src/lib/oidc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import type { JWTVerifyResult } from 'jose';
import { env } from '$env/dynamic/public';

// These are required variables from the environment.
export const issuer = env.PUBLIC_OAUTH2_ISSUER || '';
export const clientID = env.PUBLIC_OAUTH2_CLIENT_ID || '';
export const clientSecret = env.PUBLIC_OAUTH2_CLIENT_SECRET || '';
export const issuer = env.PUBLIC_IDENTITY_HOST || '';

export type IDToken = {
// openid scope.
Expand Down
44 changes: 4 additions & 40 deletions src/routes/(shell)/+layout.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
export const ssr = false;

import Base64url from 'crypto-js/enc-base64url';
import SHA256 from 'crypto-js/sha256';

import type { LayoutLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';

Expand All @@ -23,45 +20,12 @@ export const load: LayoutLoad = async ({ fetch, depends }) => {
const token = getSessionData<InternalToken>('token');
const profile = getSessionData<OIDC.IDToken>('profile');

// Not logged in, redirect to the start of the login flow, remembering where
// we were.
if (!token) {
const oidc = await OIDC.discovery(fetch);

const nonceBytes = new Uint8Array(16);
crypto.getRandomValues(nonceBytes);

const nonce = btoa(nonceBytes.toString());
const nonceHash = SHA256(nonce).toString(Base64url);

window.sessionStorage.setItem('oidc_nonce', nonce);

/* Kck off the oauth2/oidc authentication code flow */
const codeChallengeVerifierBytes = new Uint8Array(32);
crypto.getRandomValues(codeChallengeVerifierBytes);

const codeChallengeVerifier = btoa(codeChallengeVerifierBytes.toString());
const codeChallenge = SHA256(codeChallengeVerifier).toString(Base64url);

window.sessionStorage.setItem('oauth2_code_challenge_verifier', codeChallengeVerifier);
window.sessionStorage.setItem('oauth2_location', window.location.pathname);

const query = new URLSearchParams({
response_type: 'code',
client_id: OIDC.clientID,
redirect_uri: `${window.location.protocol}//${window.location.host}/oauth2/callback`,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
scope: 'openid email profile',
nonce: nonceHash
});

if (profile?.email) {
query.set('login_hint', profile.email);
}

const url = new URL(oidc.authorization_endpoint);
url.search = query.toString();
window.sessionStorage.setItem('oidc_location', window.location.pathname);

redirect(307, url.toString());
redirect(307, '/oauth2/login');
}

if (!profile) {
Expand Down
121 changes: 121 additions & 0 deletions src/routes/oauth2/callback/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
export const ssr = false;

import Base64url from 'crypto-js/enc-base64url';
import SHA256 from 'crypto-js/sha256';
import { createRemoteJWKSet, jwtVerify } from 'jose';

import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';

import type { InternalToken } from '$lib/oauth2';
import * as OIDC from '$lib/oidc';

type OAuth2Error = {
error: string;
error_description: string;
};

// All has gone well so far and the OIDC server has either given us an error or
// an authorization code that we can exchange for some tokens. For that we need
// our PKCE cookies from the browser session and the server-side client secret.
export const load: PageServerLoad = async ({ fetch, url, cookies }) => {
if (url.searchParams.has('error')) {
error(
400,
`oauth2 authentication failed with error ${url.searchParams.get('error')}: ${url.searchParams.get('error_description')}`
);
}

const nonce = cookies.get('oidc_nonce');
if (!nonce) {
error(400, 'OIDC nonce not set');
}

const codeChallengeVerifier = cookies.get('oidc_code_challenge_verifier');
if (!codeChallengeVerifier) {
error(400, 'OIDC code challenge verifier not set');
}

const clientID = env.OIDC_CLIENT_ID;
if (!clientID) {
error(400, 'OIDC client ID not set');
}

const clientSecret = env.OIDC_CLIENT_SECRET;
if (!clientSecret) {
error(400, 'OIDC client secret not set');
}

const code = url.searchParams.get('code');
if (!code) {
error(400, `oauth2 authentication did not respond with an authorization code`);
}

// Code exchange...
const discovery = await OIDC.discovery(fetch);

const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientID,
client_secret: clientSecret,
redirect_uri: `${url.protocol}//${url.host}/oauth2/callback`,
code: code,
code_verifier: codeChallengeVerifier
});

const options = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
};

const response = await fetch(discovery.token_endpoint, options);

const result = await response.json();

if (!response.ok) {
const e = result as OAuth2Error;

error(400, `oauth2 token exchange failed with error ${e.error}: ${e.error_description}`);
}

// Verify the ID token against the OIDC JWKs etc.
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));

const jwt = await jwtVerify(result.id_token, jwks, {
issuer: discovery.issuer,
audience: clientID
});

const idToken = jwt.payload as OIDC.IDToken;
if (idToken.nonce != SHA256(nonce).toString(Base64url)) {
error(400, 'OIDC ID token nonce does not match, possible replay attack');
}

// Verify the access token matches the hash in the signed ID token.
try {
OIDC.compareAccessTokenHash(jwt, result.access_token);
} catch (err) {
const e = err as Error;

error(400, `oauth2 access token error: ${e.message}`);
}

const token = result as InternalToken;

// Set the expiry time so we know when to perform a rotation.
// Add a little wiggle room in there to account for any latency.
const expiry = new Date(Date.now());
expiry.setSeconds(expiry.getSeconds() + token.expires_in - 60);

token.expiry = expiry.toJSON();

// Pass the tokens back to the client for persistence in session storage.
return {
token: token,
idToken: idToken
};
};
Loading

0 comments on commit 7d61b57

Please sign in to comment.