feat: oidc based authentication
This commit is contained in:
parent
025cc4f6f1
commit
58992efa2e
23
app/components/TabLink.tsx
Normal file
23
app/components/TabLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
app/root.tsx
123
app/root.tsx
@ -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/>
|
||||
}
|
||||
|
||||
32
app/routes/_data.machines.tsx
Normal file
32
app/routes/_data.machines.tsx
Normal 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
40
app/routes/_data.tsx
Normal 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>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
82
app/routes/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
app/routes/oidc.callback.tsx
Normal file
15
app/routes/oidc.callback.tsx
Normal 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
33
app/utils/headscale.ts
Normal 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
135
app/utils/oidc.ts
Normal 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
31
app/utils/sessions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user