feat: oidc based authentication

This commit is contained in:
Aarnav Tale 2024-03-25 17:51:11 -04:00
parent 025cc4f6f1
commit 58992efa2e
No known key found for this signature in database
10 changed files with 494 additions and 62 deletions

View File

@ -0,0 +1,23 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-sm',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
</NavLink>
)
}

View File

@ -1,29 +1,106 @@
import { ExclamationTriangleIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline'
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useRouteError
} from '@remix-run/react'
import clsx from 'clsx'
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
import stylesheet from '~/tailwind.css?url'
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }
]
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }
]
export function loader() {
if (!process.env.HEADSCALE_URL) {
throw new Error('The HEADSCALE_URL environment variable is required')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
if (!process.env.API_KEY) {
throw new Error('The API_KEY environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
<html lang='en'>
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
</head>
<body className='overscroll-none'>
{children}
<ScrollRestoration/>
<Scripts/>
</body>
</html>
)
}
export function ErrorBoundary() {
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
return (
<html>
<head>
<title>Oh no!</title>
<Meta/>
<Links/>
</head>
<body>
<div className='flex min-h-screen items-center justify-center'>
<div className={clsx(
'w-1/3 border p-4 rounded-lg flex flex-col items-center text-center',
routing ? 'gap-2' : 'gap-4'
)}
>
{routing ? (
<>
<QuestionMarkCircleIcon className='text-gray-500 w-14 h-14'/>
<h1 className='text-2xl font-bold'>{error.status}</h1>
<p className='opacity-50 text-sm'>{error.statusText}</p>
</>
) : (
<>
<ExclamationTriangleIcon className='text-red-500 w-14 h-14'/>
<h1 className='text-2xl font-bold'>Error</h1>
<code className='bg-gray-100 p-1 rounded-md'>
{message}
</code>
<p className='opacity-50 text-sm mt-4'>
If you are the administrator of this site, please check your logs for information.
</p>
</>
)}
</div>
</div>
<Scripts/>
</body>
</html>
)
}
export default function App() {
return <Outlet />;
return <Outlet/>
}

View File

@ -0,0 +1,32 @@
export default function Index() {
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<h1>Welcome to Remix</h1>
<ul>
<li>
<a
target='_blank'
href='https://remix.run/tutorials/blog'
rel='noreferrer'
>
15m Quickstart Blog Tutorial
</a>
</li>
<li>
<a
target='_blank'
href='https://remix.run/tutorials/jokes'
rel='noreferrer'
>
Deep Dive Jokes App Tutorial
</a>
</li>
<li>
<a target='_blank' href='https://remix.run/docs' rel='noreferrer'>
Remix Docs
</a>
</li>
</ul>
</div>
)
}

40
app/routes/_data.tsx Normal file
View File

@ -0,0 +1,40 @@
import { CpuChipIcon, ServerStackIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet } from '@remix-run/react'
import TabLink from '~/components/TabLink'
import { getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export default function Layout() {
return (
<>
<header className='bg-gray-800 text-white mb-16'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-4 h-4'/>}/>
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain'>
<Outlet/>
</main>
</>
)
}

View File

@ -1,41 +1,5 @@
import type { MetaFunction } from "@remix-run/node";
import { redirect } from '@remix-run/node'
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to Remix</h1>
<ul>
<li>
<a
target="_blank"
href="https://remix.run/tutorials/blog"
rel="noreferrer"
>
15m Quickstart Blog Tutorial
</a>
</li>
<li>
<a
target="_blank"
href="https://remix.run/tutorials/jokes"
rel="noreferrer"
>
Deep Dive Jokes App Tutorial
</a>
</li>
<li>
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
Remix Docs
</a>
</li>
</ul>
</div>
);
export function loader() {
return redirect('/machines')
}

82
app/routes/login.tsx Normal file
View File

@ -0,0 +1,82 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import { startOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
const secret = process.env.OIDC_CLIENT_SECRET
const normal = process.env.DISABLE_API_KEY_LOGIN
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
}
const data = {
oidc: issuer,
apiKey: normal === undefined
}
console.log(data)
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='w-1/3 border p-4 rounded-lg'>
<h1 className='text-2xl mb-8'>Login</h1>
{data.apiKey ? (
<>
<p className='text-sm text-gray-500 mb-4'>
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<code className='bg-gray-100 p-1 rounded-md'>
headscale apikeys create
</code>
{' '}
in your terminal.
</p>
<input
type='text'
id='api-key'
className='border rounded-md p-2 w-full'
placeholder='API Key'
/>
</>
) : undefined}
{showOr ? (
<div className='flex items-center gap-x-2 py-2'>
<hr className='flex-1'/>
<span className='text-gray-500'>or</span>
<hr className='flex-1'/>
</div>
) : undefined}
{data.oidc ? (
<Link to='/oidc/start'>
<button className='bg-gray-800 text-white rounded-md p-2 w-full' type='button'>
Login with SSO
</button>
</Link>
) : undefined}
</div>
</div>
)
}

View File

@ -0,0 +1,15 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
const secret = process.env.OIDC_CLIENT_SECRET
if (!issuer || !id || !secret) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(issuer, id, secret, request)
}

33
app/utils/headscale.ts Normal file
View File

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
export async function pull<T>(url: string, key: string) {
const prefix = process.env.HEADSCALE_URL!
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new Error(response.statusText)
}
return response.json() as Promise<T>
}
export async function post<T>(url: string, key: string, body?: unknown) {
const prefix = process.env.HEADSCALE_URL!
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new Error(await response.text())
}
return response.json() as Promise<T>
}

135
app/utils/oidc.ts Normal file
View File

@ -0,0 +1,135 @@
import { redirect } from '@remix-run/node'
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge, type Client,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
generateRandomState,
getValidatedIdTokenClaims, isOAuth2Error,
parseWwwAuthenticateChallenges,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
validateAuthResponse } from 'oauth4webapi'
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
export async function startOidc(issuer: string, client: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const issuerUrl = new URL(issuer)
const oidcClient = {
client_id: client,
token_endpoint_auth_method: 'client_secret_basic'
} satisfies Client
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider')
}
const state = generateRandomState()
const nonce = generateRandomNonce()
const verifier = generateRandomCodeVerifier()
const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', request.url)
const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('redirect_uri', callback.href)
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('nonce', nonce)
session.set('authState', state)
session.set('authNonce', nonce)
session.set('authVerifier', verifier)
return redirect(authUrl.href, {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
export async function finishOidc(issuer: string, client: string, secret: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const issuerUrl = new URL(issuer)
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_basic'
} satisfies Client
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider')
}
const state = session.get('authState')
const nonce = session.get('authNonce')
const verifier = session.get('authVerifier')
if (!state || !nonce || !verifier) {
throw new Error('No OIDC state found in the session')
}
const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state)
if (isOAuth2Error(parameters)) {
throw new Error('Invalid response from the OIDC provider')
}
const callback = new URL('/admin/oidc/callback', request.url)
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
if (challenges) {
throw new Error('Recieved a challenge from the OIDC provider')
}
const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce)
if (isOAuth2Error(result)) {
throw new Error('Invalid response from the OIDC provider')
}
const claims = getValidatedIdTokenClaims(result)
const expDate = new Date(claims.exp * 1000).toISOString()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, {
expiration: expDate
})
session.set('hsApiKey', keyResponse.apiKey)
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}

31
app/utils/sessions.ts Normal file
View File

@ -0,0 +1,31 @@
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
type SessionData = {
hsApiKey: string;
authState: string;
authNonce: string;
authVerifier: string;
}
type SessionFlashData = {
error: string;
}
export const {
getSession,
commitSession,
destroySession
} = createCookieSessionStorage<SessionData, SessionFlashData>(
{
cookie: {
name: 'hp_sess',
httpOnly: true,
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
secrets: [process.env.COOKIE_SECRET!],
secure: true
}
}
)