Add OAuth2/OpenID Connect authorization server

Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-30 14:49:18 +02:00
parent e84094e62c
commit 11770b4058
155 changed files with 14483 additions and 223 deletions

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, useToast } from "@probo/ui";
import { useCallback, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { ConsentPageMutation } from "#/__generated__/iam/ConsentPageMutation.graphql";
import type { ConsentPageQuery } from "#/__generated__/iam/ConsentPageQuery.graphql";
export const consentPageQuery = graphql`
query ConsentPageQuery($consentId: ID!) {
node(id: $consentId) @required(action: THROW) {
... on Consent {
id
application {
name
}
scopes
}
}
}
`;
const approveConsentMutation = graphql`
mutation ConsentPageMutation($input: ApproveConsentInput!) {
approveConsent(input: $input) {
redirectURL
deviceAuthorized
}
}
`;
const scopeLabels: Record<string, string> = {
openid: "Verify your identity",
email: "View your email address",
profile: "View your profile information",
offline_access: "Stay signed in and access your data while you're away",
};
function ScopeIcon({ scope }: { scope: string }) {
switch (scope) {
case "openid":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-[18px] h-[18px] shrink-0 text-txt-tertiary"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
);
case "email":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-[18px] h-[18px] shrink-0 text-txt-tertiary"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
/>
</svg>
);
case "profile":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-[18px] h-[18px] shrink-0 text-txt-tertiary"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
);
case "offline_access":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-[18px] h-[18px] shrink-0 text-txt-tertiary"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182M21.015 4.356v4.992"
/>
</svg>
);
default:
return null;
}
}
export default function ConsentPage(props: {
queryRef: PreloadedQuery<ConsentPageQuery>;
}) {
const { __ } = useTranslate();
const { toast } = useToast();
const [deviceResult, setDeviceResult] = useState<"authorized" | "denied" | null>(null);
const data = usePreloadedQuery(consentPageQuery, props.queryRef);
usePageTitle(__("Authorize Application"));
const { node: consent } = data;
const [approveConsent, isInFlight]
= useMutation<ConsentPageMutation>(approveConsentMutation);
const handleAction = useCallback(
(approved: boolean) => {
if (!consent.id) return;
approveConsent({
variables: {
input: {
consentId: consent.id,
approved,
},
},
onCompleted: (response, errors) => {
if (errors) {
toast({
title: __("Authorization failed"),
description: formatError(
__("Something went wrong. Please try again."),
errors,
),
variant: "error",
});
return;
}
if (!response.approveConsent) {
toast({
title: __("Authorization failed"),
description: __("Something went wrong. Please try again."),
variant: "error",
});
return;
}
if (response.approveConsent.deviceAuthorized != null) {
setDeviceResult(response.approveConsent.deviceAuthorized ? "authorized" : "denied");
return;
}
if (response.approveConsent.redirectURL) {
window.location.href = response.approveConsent.redirectURL;
}
},
onError: (err) => {
toast({
title: __("Error"),
description:
err.message || __("Something went wrong. Please try again."),
variant: "error",
});
},
});
},
[consent, approveConsent, __, toast],
);
if (!consent.application || !consent.scopes) {
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6 text-center">
<h1 className="text-2xl font-bold">{__("Invalid Request")}</h1>
<p className="text-txt-tertiary">
{__("This consent request is invalid or has expired.")}
</p>
</div>
);
}
if (deviceResult === "authorized") {
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6 text-center">
<h1 className="text-2xl font-bold">{__("Device Authorized")}</h1>
<p className="text-txt-tertiary">
{__("Your device has been successfully authorized. You can close this window and return to your device.")}
</p>
</div>
);
}
if (deviceResult === "denied") {
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6 text-center">
<h1 className="text-2xl font-bold">{__("Access Denied")}</h1>
<p className="text-txt-tertiary">
{__("You have denied the authorization request. You can close this window.")}
</p>
</div>
);
}
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6">
<div className="space-y-2 text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-level-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 10.5V6.75a4.5 4.5 0 1 1 9 0v3.75M3.75 21.75h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H3.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
</div>
<h1 className="text-2xl font-bold">
{__("Authorize")}
{" "}
<span className="font-bold">{consent.application.name}</span>
</h1>
<p className="text-txt-tertiary text-sm">
{__(
"This application is requesting access to your account with the following permissions:",
)}
</p>
</div>
<ul className="space-y-2">
{consent.scopes.map((scope: string) => {
const label = scopeLabels[scope];
if (!label) return null;
return (
<li
key={scope}
className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary border border-border-mid rounded-lg"
>
<ScopeIcon scope={scope} />
{__(label)}
</li>
);
})}
</ul>
<div className="flex gap-3">
<Button
variant="secondary"
className="flex-1 h-10"
disabled={isInFlight}
onClick={() => handleAction(false)}
>
{__("Deny")}
</Button>
<Button
className="flex-1 h-10"
disabled={isInFlight}
onClick={() => handleAction(true)}
>
{isInFlight ? __("Authorizing...") : __("Allow")}
</Button>
</div>
<p className="text-center text-xs text-txt-tertiary">
{__("You can revoke access at any time from your account settings.")}
</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Component, type ReactNode, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useSearchParams } from "react-router";
import type { ConsentPageQuery } from "#/__generated__/iam/ConsentPageQuery.graphql";
import ConsentPage, { consentPageQuery } from "./ConsentPage";
function ConsentPageQueryLoader() {
const [searchParams] = useSearchParams();
const consentId = searchParams.get("consent_id") ?? "";
const [queryRef, loadQuery]
= useQueryLoader<ConsentPageQuery>(consentPageQuery);
useEffect(() => {
loadQuery({ consentId });
}, [loadQuery, consentId]);
if (!queryRef) return null;
return <ConsentPage queryRef={queryRef} />;
}
class ConsentErrorBoundary extends Component<
{ fallback: ReactNode; children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function ConsentErrorFallback() {
const { __ } = useTranslate();
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6 text-center">
<h1 className="text-2xl font-bold">{__("Invalid Request")}</h1>
<p className="text-txt-tertiary">
{__("This consent request is invalid or has expired.")}
</p>
</div>
);
}
export default function ConsentPageLoader() {
return (
<ConsentErrorBoundary fallback={<ConsentErrorFallback />}>
<ConsentPageQueryLoader />
</ConsentErrorBoundary>
);
}

View File

@@ -0,0 +1,224 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, useToast } from "@probo/ui";
import {
type ClipboardEvent,
type KeyboardEvent,
useCallback,
useRef,
useState,
} from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import type { DeviceActivationPageMutation } from "#/__generated__/iam/DeviceActivationPageMutation.graphql";
import type { DeviceActivationPageQuery } from "#/__generated__/iam/DeviceActivationPageQuery.graphql";
export const deviceActivationPageQuery = graphql`
query DeviceActivationPageQuery {
viewer {
__typename
}
}
`;
const authorizeDeviceMutation = graphql`
mutation DeviceActivationPageMutation($input: AuthorizeDeviceInput!) {
authorizeDevice(input: $input) {
success
consentId
}
}
`;
export default function DeviceActivationPage(props: {
queryRef: PreloadedQuery<DeviceActivationPageQuery>;
}) {
const { __ } = useTranslate();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
usePreloadedQuery(deviceActivationPageQuery, props.queryRef);
usePageTitle(__("Device Activation"));
const preset = (searchParams.get("user_code") ?? "").replace(/-/g, "");
const [values, setValues] = useState<string[]>(() => {
const chars = preset.split("").slice(0, 8);
return Array.from({ length: 8 }, (_, i) => chars[i] ?? "");
});
const [status, setStatus] = useState<"idle" | "success">("idle");
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const [authorizeDevice, isInFlight]
= useMutation<DeviceActivationPageMutation>(authorizeDeviceMutation);
const syncAndFocus = useCallback(
(next: string[], focusIdx?: number) => {
setValues(next);
if (focusIdx !== undefined && inputRefs.current[focusIdx]) {
inputRefs.current[focusIdx].focus();
}
},
[],
);
const handleInput = useCallback(
(idx: number, char: string) => {
const cleaned = char.replace(/[^a-zA-Z0-9]/g, "").slice(0, 1);
const next = [...values];
next[idx] = cleaned;
syncAndFocus(next, cleaned ? Math.min(idx + 1, 7) : undefined);
},
[values, syncAndFocus],
);
const handleKeyDown = useCallback(
(idx: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !values[idx] && idx > 0) {
const next = [...values];
next[idx - 1] = "";
syncAndFocus(next, idx - 1);
}
},
[values, syncAndFocus],
);
const handlePaste = useCallback(
(idx: number, e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const text = e.clipboardData.getData("text").replace(/[^a-zA-Z0-9]/g, "");
const next = [...values];
for (let j = 0; j < text.length && idx + j < 8; j++) {
next[idx + j] = text[j];
}
syncAndFocus(next, Math.min(idx + text.length, 7));
},
[values, syncAndFocus],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const code = values.join("").toUpperCase();
if (code.length !== 8) return;
const userCode = code.slice(0, 4) + "-" + code.slice(4);
authorizeDevice({
variables: { input: { userCode } },
onCompleted: (response, errors) => {
if (errors) {
toast({
title: __("Authorization failed"),
description: formatError(
__("The code is invalid or has expired."),
errors,
),
variant: "error",
});
return;
}
const result = response.authorizeDevice;
if (!result) return;
if (result.success) {
setStatus("success");
} else if (result.consentId) {
void navigate(`/auth/consent?consent_id=${result.consentId}`);
}
},
onError: (err) => {
toast({
title: __("Error"),
description: err.message || __("Something went wrong. Please try again."),
variant: "error",
});
},
});
},
[values, authorizeDevice, __, toast, navigate],
);
const isFilled = values.every(v => v.length === 1);
if (status === "success") {
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6 text-center">
<h1 className="text-2xl font-bold">{__("Device Authorized")}</h1>
<p className="text-txt-tertiary">
{__("Your device has been successfully authorized. You can close this window and return to your device.")}
</p>
</div>
);
}
return (
<div className="w-full max-w-md mx-auto pt-8 space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">{__("Device Activation")}</h1>
<p className="text-txt-tertiary">
{__("Enter the code displayed on your device")}
</p>
</div>
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<div className="flex items-center justify-center gap-2">
{values.map((val, idx) => (
<div key={idx} className="contents">
{idx === 4 && (
<span className="text-xl text-txt-tertiary select-none px-0.5">&ndash;</span>
)}
<input
ref={(el) => { inputRefs.current[idx] = el; }}
type="text"
inputMode="text"
maxLength={1}
value={val}
onChange={e => handleInput(idx, e.target.value)}
onKeyDown={e => handleKeyDown(idx, e)}
onPaste={e => handlePaste(idx, e)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="characters"
spellCheck={false}
autoFocus={idx === 0}
aria-label={`${__("Code character")} ${idx + 1}`}
className="w-11 h-13 text-center text-lg font-mono font-medium uppercase rounded-lg border border-border-mid bg-level-1 text-txt-primary outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
</div>
))}
</div>
<Button
type="submit"
className="w-full h-10"
disabled={!isFilled || isInFlight}
>
{isInFlight ? __("Authorizing...") : __("Continue")}
</Button>
</form>
<p className="text-center text-sm text-txt-tertiary">
{__("Make sure this code matches the one on your device.")}
</p>
</div>
);
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { DeviceActivationPageQuery } from "#/__generated__/iam/DeviceActivationPageQuery.graphql";
import DeviceActivationPage, { deviceActivationPageQuery } from "./DeviceActivationPage";
function DeviceActivationPageQueryLoader() {
const [queryRef, loadQuery]
= useQueryLoader<DeviceActivationPageQuery>(deviceActivationPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) return null;
return <DeviceActivationPage queryRef={queryRef} />;
}
export default function DeviceActivationPageLoader() {
return <DeviceActivationPageQueryLoader />;
}

View File

@@ -98,6 +98,20 @@ const routes = [
path: "reset-password",
Component: lazy(() => import("./pages/iam/auth/ResetPasswordPage")),
},
{
path: "device",
ErrorBoundary: RootErrorBoundary,
Component: lazy(
() => import("./pages/iam/auth/DeviceActivationPageLoader"),
),
},
{
path: "consent",
ErrorBoundary: RootErrorBoundary,
Component: lazy(
() => import("./pages/iam/auth/ConsentPageLoader"),
),
},
],
},
{