mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 08:49:30 +00:00
feat: oidc based authentication
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+100
-23
@@ -1,29 +1,106 @@
|
|||||||
|
import { ExclamationTriangleIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { LinksFunction, MetaFunction } from '@remix-run/node'
|
||||||
import {
|
import {
|
||||||
Links,
|
isRouteErrorResponse,
|
||||||
Meta,
|
Links,
|
||||||
Outlet,
|
Meta,
|
||||||
Scripts,
|
Outlet,
|
||||||
ScrollRestoration,
|
Scripts,
|
||||||
} from "@remix-run/react";
|
ScrollRestoration,
|
||||||
|
useRouteError
|
||||||
|
} from '@remix-run/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
import stylesheet from '~/tailwind.css?url'
|
||||||
return (
|
|
||||||
<html lang="en">
|
export const meta: MetaFunction = () => [
|
||||||
<head>
|
{ title: 'Headplane' },
|
||||||
<meta charSet="utf-8" />
|
{ name: 'description', content: 'A frontend for the headscale coordination server' }
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
]
|
||||||
<Meta />
|
|
||||||
<Links />
|
export const links: LinksFunction = () => [
|
||||||
</head>
|
{ rel: 'stylesheet', href: stylesheet }
|
||||||
<body>
|
]
|
||||||
{children}
|
|
||||||
<ScrollRestoration />
|
export function loader() {
|
||||||
<Scripts />
|
if (!process.env.HEADSCALE_URL) {
|
||||||
</body>
|
throw new Error('The HEADSCALE_URL environment variable is required')
|
||||||
</html>
|
}
|
||||||
);
|
|
||||||
|
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() {
|
export default function App() {
|
||||||
return <Outlet />;
|
return <Outlet/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
+3
-39
@@ -1,41 +1,5 @@
|
|||||||
import type { MetaFunction } from "@remix-run/node";
|
import { redirect } from '@remix-run/node'
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export function loader() {
|
||||||
return [
|
return redirect('/machines')
|
||||||
{ 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Reference in New Issue
Block a user