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:
306
apps/console/src/pages/iam/auth/ConsentPage.tsx
Normal file
306
apps/console/src/pages/iam/auth/ConsentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/console/src/pages/iam/auth/ConsentPageLoader.tsx
Normal file
77
apps/console/src/pages/iam/auth/ConsentPageLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
apps/console/src/pages/iam/auth/DeviceActivationPage.tsx
Normal file
224
apps/console/src/pages/iam/auth/DeviceActivationPage.tsx
Normal 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">–</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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
3385
e2e/console/oauth2_test.go
Normal file
3385
e2e/console/oauth2_test.go
Normal file
File diff suppressed because it is too large
Load Diff
9
e2e/console/testdata/config.yaml
vendored
9
e2e/console/testdata/config.yaml
vendored
@@ -38,6 +38,15 @@ probod:
|
||||
password:
|
||||
pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes"
|
||||
iterations: 600000
|
||||
oauth2-server:
|
||||
signing-keys:
|
||||
- key-file: "./testdata/oauth2_signing_key.pem"
|
||||
kid: "test-key-1"
|
||||
active: true
|
||||
access-token-duration: 10
|
||||
refresh-token-duration: 10
|
||||
authorization-code-duration: 5
|
||||
device-code-duration: 15
|
||||
|
||||
trust-center:
|
||||
http-addr: ":10080"
|
||||
|
||||
@@ -17,6 +17,7 @@ package factory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
@@ -1217,3 +1218,63 @@ func CreateApplicabilityStatement(c *testutil.Client, soaID, controlID string, a
|
||||
|
||||
return result.CreateApplicabilityStatement.ApplicabilityStatementEdge.Node.ID
|
||||
}
|
||||
|
||||
type OAuth2ClientResult struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func CreateOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
||||
input := map[string]any{
|
||||
"organization_id": c.GetOrganizationID().String(),
|
||||
"client_name": SafeName("OAuth2 Client"),
|
||||
"visibility": "private",
|
||||
"redirect_uris": []string{"http://localhost:9999/callback"},
|
||||
"grant_types": []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
},
|
||||
"response_types": []string{"code"},
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"scopes": "openid email profile offline_access",
|
||||
}
|
||||
|
||||
maps.Copy(input, attrs)
|
||||
|
||||
resp, raw, err := testutil.OAuth2RegisterClient(c, input)
|
||||
require.NoError(c.T, err, "OAuth2 client registration failed")
|
||||
require.NotNil(c.T, resp, "OAuth2 client registration returned nil (status=%d body=%s)", raw.StatusCode, string(raw.Body))
|
||||
|
||||
return OAuth2ClientResult{
|
||||
ClientID: resp.ClientID,
|
||||
ClientSecret: resp.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func CreatePublicOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
||||
input := map[string]any{
|
||||
"organization_id": c.GetOrganizationID().String(),
|
||||
"client_name": SafeName("Public OAuth2 Client"),
|
||||
"visibility": "private",
|
||||
"redirect_uris": []string{"http://localhost:9999/callback"},
|
||||
"grant_types": []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
"response_types": []string{"code"},
|
||||
"token_endpoint_auth_method": "none",
|
||||
"scopes": "openid email profile offline_access",
|
||||
}
|
||||
|
||||
maps.Copy(input, attrs)
|
||||
|
||||
resp, raw, err := testutil.OAuth2RegisterClient(c, input)
|
||||
require.NoError(c.T, err, "public OAuth2 client registration failed")
|
||||
require.NotNil(c.T, resp, "public OAuth2 client registration returned nil (status=%d body=%s)", raw.StatusCode, string(raw.Body))
|
||||
|
||||
return OAuth2ClientResult{
|
||||
ClientID: resp.ClientID,
|
||||
ClientSecret: resp.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ type Client struct {
|
||||
userID gid.GID
|
||||
profileID gid.GID
|
||||
organizationID gid.GID
|
||||
email string
|
||||
password string
|
||||
}
|
||||
|
||||
func NewClient(t testing.TB, role TestRole) *Client {
|
||||
@@ -107,6 +109,9 @@ func (c *Client) setupTestUser() {
|
||||
password := "TestPassword123!"
|
||||
fullName := fmt.Sprintf("Test User %s", uniqueID)
|
||||
|
||||
c.email = email
|
||||
c.password = password
|
||||
|
||||
// Sign up
|
||||
c.userID = c.signUp(email, password, fullName)
|
||||
|
||||
@@ -491,6 +496,35 @@ func (c *Client) assumeOrganizationSession() {
|
||||
require.NoError(c.T, err, "assumeOrganizationSession mutation failed")
|
||||
}
|
||||
|
||||
// NewClientWithNewSession creates a new Client that signs in as the same
|
||||
// identity but with a fresh HTTP session (new cookie jar). This is useful for
|
||||
// testing session-scoped authorization.
|
||||
func NewClientWithNewSession(t testing.TB, from *Client) *Client {
|
||||
t.Helper()
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err, "cannot create cookie jar")
|
||||
|
||||
client := &Client{
|
||||
T: t,
|
||||
baseURL: from.baseURL,
|
||||
mailpitBaseURL: from.mailpitBaseURL,
|
||||
role: from.role,
|
||||
userID: from.userID,
|
||||
organizationID: from.organizationID,
|
||||
email: from.email,
|
||||
password: from.password,
|
||||
httpClient: &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
client.signIn(client.email, client.password)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) GetUserID() gid.GID {
|
||||
return c.userID
|
||||
}
|
||||
|
||||
909
e2e/internal/testutil/oauth2.go
Normal file
909
e2e/internal/testutil/oauth2.go
Normal file
@@ -0,0 +1,909 @@
|
||||
// Copyright (c) 2025-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.
|
||||
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
OAuth2ErrorResponse struct {
|
||||
Code string `json:"error"`
|
||||
Description string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
OAuth2RegisterResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ClientName string `json:"client_name"`
|
||||
Visibility string `json:"visibility"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
|
||||
OAuth2IntrospectResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
}
|
||||
|
||||
OAuth2DeviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
OAuth2DiscoveryResponse struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"`
|
||||
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
}
|
||||
|
||||
OAuth2JWKSResponse struct {
|
||||
Keys []map[string]any `json:"keys"`
|
||||
}
|
||||
|
||||
OAuth2UserInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
OAuth2HTTPResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
)
|
||||
|
||||
func oauth2BaseURL(c *Client) string {
|
||||
return c.BaseURL() + "/api/connect/v1/oauth2"
|
||||
}
|
||||
|
||||
func postForm(
|
||||
httpClient *http.Client,
|
||||
url string,
|
||||
values url.Values,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
resp, err := httpClient.PostForm(url, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot post form: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
|
||||
}
|
||||
|
||||
func postJSON(
|
||||
httpClient *http.Client,
|
||||
url string,
|
||||
payload any,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Post(url, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot post json: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
|
||||
}
|
||||
|
||||
func getJSON(
|
||||
httpClient *http.Client,
|
||||
url string,
|
||||
headers map[string]string,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
|
||||
}
|
||||
|
||||
func postFormWithBasicAuth(
|
||||
httpClient *http.Client,
|
||||
rawURL string,
|
||||
values url.Values,
|
||||
username, password string,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
req, err := http.NewRequest(
|
||||
"POST",
|
||||
rawURL,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
|
||||
}
|
||||
|
||||
// OAuth2Discovery fetches the OpenID Connect discovery document.
|
||||
func OAuth2Discovery(c *Client) (*OAuth2DiscoveryResponse, *OAuth2HTTPResponse, error) {
|
||||
raw, err := getJSON(c.HTTPClient(), c.BaseURL()+"/.well-known/openid-configuration", nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2DiscoveryResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode discovery response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2JWKS fetches the JSON Web Key Set.
|
||||
func OAuth2JWKS(c *Client) (*OAuth2JWKSResponse, *OAuth2HTTPResponse, error) {
|
||||
raw, err := getJSON(c.HTTPClient(), oauth2BaseURL(c)+"/jwks", nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2JWKSResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode jwks response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2RegisterClient registers a new OAuth2 client via dynamic registration.
|
||||
func OAuth2RegisterClient(
|
||||
c *Client,
|
||||
input map[string]any,
|
||||
) (*OAuth2RegisterResponse, *OAuth2HTTPResponse, error) {
|
||||
raw, err := postJSON(c.HTTPClient(), oauth2BaseURL(c)+"/register", input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusCreated {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2RegisterResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode register response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2Authorize performs a GET to the authorize endpoint and returns the
|
||||
// raw HTTP response without following redirects.
|
||||
func OAuth2Authorize(
|
||||
c *Client,
|
||||
params url.Values,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
noRedirectClient := &http.Client{
|
||||
Jar: c.HTTPClient().Jar,
|
||||
Timeout: c.HTTPClient().Timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
reqURL := oauth2BaseURL(c) + "/authorize?" + params.Encode()
|
||||
resp, err := noRedirectClient.Get(reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get authorize: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
|
||||
}
|
||||
|
||||
// OAuth2AuthorizeCodeFromRedirect extracts the authorization code from the
|
||||
// Location header of a 302 response.
|
||||
func OAuth2AuthorizeCodeFromRedirect(resp *OAuth2HTTPResponse) (string, error) {
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("no Location header in redirect response (status=%d body=%s)", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse redirect url: %w", err)
|
||||
}
|
||||
|
||||
code := u.Query().Get("code")
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("no code in redirect url: %s", loc)
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// OAuth2ConsentApprove approves an OAuth2 consent via the GraphQL mutation.
|
||||
// It returns a simulated HTTP 302 response with the redirect URL in the
|
||||
// Location header so existing callers can extract the authorization code.
|
||||
func OAuth2ConsentApprove(c *Client, consentID string) (*OAuth2HTTPResponse, error) {
|
||||
return oauth2ConsentDecide(c, consentID, true)
|
||||
}
|
||||
|
||||
// OAuth2ConsentDeny denies an OAuth2 consent via the GraphQL mutation.
|
||||
// It returns a simulated HTTP 302 response with the redirect URL in the
|
||||
// Location header so existing callers can inspect the error parameters.
|
||||
func OAuth2ConsentDeny(c *Client, consentID string) (*OAuth2HTTPResponse, error) {
|
||||
return oauth2ConsentDecide(c, consentID, false)
|
||||
}
|
||||
|
||||
func oauth2ConsentDecide(c *Client, consentID string, approved bool) (*OAuth2HTTPResponse, error) {
|
||||
const query = `
|
||||
mutation ApproveConsent($input: ApproveConsentInput!) {
|
||||
approveConsent(input: $input) {
|
||||
redirectURL
|
||||
deviceAuthorized
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
ApproveConsent struct {
|
||||
RedirectURL *string `json:"redirectURL"`
|
||||
DeviceAuthorized *bool `json:"deviceAuthorized"`
|
||||
} `json:"approveConsent"`
|
||||
}
|
||||
|
||||
err := c.ExecuteConnect(
|
||||
query,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"consentId": consentID,
|
||||
"approved": approved,
|
||||
},
|
||||
},
|
||||
&result,
|
||||
)
|
||||
if err != nil {
|
||||
return &OAuth2HTTPResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{},
|
||||
Body: []byte(err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp := &OAuth2HTTPResponse{
|
||||
StatusCode: http.StatusFound,
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
if result.ApproveConsent.RedirectURL != nil {
|
||||
resp.Header.Set("Location", *result.ApproveConsent.RedirectURL)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenWithCode exchanges an authorization code for tokens.
|
||||
func OAuth2TokenWithCode(
|
||||
c *Client,
|
||||
clientID, clientSecret, code, redirectURI, codeVerifier string,
|
||||
) (*OAuth2TokenResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
|
||||
if codeVerifier != "" {
|
||||
values.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
|
||||
raw, err := postFormWithBasicAuth(
|
||||
c.HTTPClient(),
|
||||
oauth2BaseURL(c)+"/token",
|
||||
values,
|
||||
clientID,
|
||||
clientSecret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2TokenResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenWithCodePostAuth exchanges an authorization code for tokens
|
||||
// using client_secret_post authentication (credentials in POST body).
|
||||
func OAuth2TokenWithCodePostAuth(
|
||||
c *Client,
|
||||
clientID, clientSecret, code, redirectURI, codeVerifier string,
|
||||
) (*OAuth2TokenResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {clientSecret},
|
||||
}
|
||||
|
||||
if codeVerifier != "" {
|
||||
values.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
|
||||
raw, err := postForm(c.HTTPClient(), oauth2BaseURL(c)+"/token", values)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2TokenResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenWithRefreshToken refreshes tokens using a refresh token.
|
||||
func OAuth2TokenWithRefreshToken(
|
||||
c *Client,
|
||||
clientID, clientSecret, refreshToken string,
|
||||
) (*OAuth2TokenResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
}
|
||||
|
||||
raw, err := postFormWithBasicAuth(
|
||||
c.HTTPClient(),
|
||||
oauth2BaseURL(c)+"/token",
|
||||
values,
|
||||
clientID,
|
||||
clientSecret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2TokenResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenWithDeviceCode polls the token endpoint for device code grant.
|
||||
func OAuth2TokenWithDeviceCode(
|
||||
c *Client,
|
||||
clientID, deviceCode string,
|
||||
) (*OAuth2TokenResponse, *OAuth2ErrorResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceCode},
|
||||
}
|
||||
|
||||
raw, err := postForm(c.HTTPClient(), oauth2BaseURL(c)+"/token", values)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode == http.StatusOK {
|
||||
var result OAuth2TokenResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, nil, raw, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
return &result, nil, raw, nil
|
||||
}
|
||||
|
||||
var errResp OAuth2ErrorResponse
|
||||
if err := json.Unmarshal(raw.Body, &errResp); err != nil {
|
||||
return nil, nil, raw, nil
|
||||
}
|
||||
|
||||
return nil, &errResp, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenRaw posts arbitrary form values to the token endpoint.
|
||||
func OAuth2TokenRaw(
|
||||
c *Client,
|
||||
values url.Values,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
raw, err := postForm(c.HTTPClient(), oauth2BaseURL(c)+"/token", values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// OAuth2TokenRawWithBasicAuth posts form values to the token endpoint with
|
||||
// HTTP Basic authentication.
|
||||
func OAuth2TokenRawWithBasicAuth(
|
||||
c *Client,
|
||||
values url.Values,
|
||||
username, password string,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
raw, err := postFormWithBasicAuth(
|
||||
c.HTTPClient(),
|
||||
oauth2BaseURL(c)+"/token",
|
||||
values,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// OAuth2DeviceAuth starts the device authorization flow.
|
||||
func OAuth2DeviceAuth(
|
||||
c *Client,
|
||||
clientID, scope string,
|
||||
) (*OAuth2DeviceAuthResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"client_id": {clientID},
|
||||
}
|
||||
|
||||
if scope != "" {
|
||||
values.Set("scope", scope)
|
||||
}
|
||||
|
||||
raw, err := postForm(c.HTTPClient(), oauth2BaseURL(c)+"/device", values)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2DeviceAuthResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode device auth response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2DeviceVerify authorizes a device code via the GraphQL authorizeDevice
|
||||
// mutation. It performs the full consent flow: submitting the user code, and if
|
||||
// consent is required, approving it via approveOAuth2Consent.
|
||||
func OAuth2DeviceVerify(c *Client, userCode string) (*OAuth2HTTPResponse, error) {
|
||||
const authorizeQuery = `
|
||||
mutation AuthorizeDevice($input: AuthorizeDeviceInput!) {
|
||||
authorizeDevice(input: $input) {
|
||||
success
|
||||
consentId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var authorizeResult struct {
|
||||
AuthorizeDevice struct {
|
||||
Success bool `json:"success"`
|
||||
ConsentID *string `json:"consentId"`
|
||||
} `json:"authorizeDevice"`
|
||||
}
|
||||
|
||||
err := c.ExecuteConnect(
|
||||
authorizeQuery,
|
||||
map[string]any{
|
||||
"input": map[string]any{"userCode": userCode},
|
||||
},
|
||||
&authorizeResult,
|
||||
)
|
||||
if err != nil {
|
||||
body, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
return &OAuth2HTTPResponse{StatusCode: http.StatusOK, Body: body}, nil
|
||||
}
|
||||
|
||||
if authorizeResult.AuthorizeDevice.Success {
|
||||
return &OAuth2HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"success":true}`)}, nil
|
||||
}
|
||||
|
||||
consentID := authorizeResult.AuthorizeDevice.ConsentID
|
||||
if consentID == nil {
|
||||
body, _ := json.Marshal(map[string]string{"error": "unexpected response"})
|
||||
return &OAuth2HTTPResponse{StatusCode: http.StatusInternalServerError, Body: body}, nil
|
||||
}
|
||||
|
||||
const approveQuery = `
|
||||
mutation ApproveConsent($input: ApproveConsentInput!) {
|
||||
approveConsent(input: $input) {
|
||||
deviceAuthorized
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var approveResult struct {
|
||||
ApproveConsent struct {
|
||||
DeviceAuthorized *bool `json:"deviceAuthorized"`
|
||||
} `json:"approveConsent"`
|
||||
}
|
||||
|
||||
err = c.ExecuteConnect(
|
||||
approveQuery,
|
||||
map[string]any{
|
||||
"input": map[string]any{"consentId": *consentID, "approved": true},
|
||||
},
|
||||
&approveResult,
|
||||
)
|
||||
if err != nil {
|
||||
body, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
return &OAuth2HTTPResponse{StatusCode: http.StatusOK, Body: body}, nil
|
||||
}
|
||||
|
||||
return &OAuth2HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"success":true}`)}, nil
|
||||
}
|
||||
|
||||
// OAuth2UserInfo fetches the UserInfo endpoint with a Bearer token.
|
||||
func OAuth2UserInfo(
|
||||
c *Client,
|
||||
accessToken string,
|
||||
) (*OAuth2UserInfoResponse, *OAuth2HTTPResponse, error) {
|
||||
headers := map[string]string{}
|
||||
if accessToken != "" {
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
}
|
||||
|
||||
raw, err := getJSON(c.HTTPClient(), oauth2BaseURL(c)+"/userinfo", headers)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2UserInfoResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode userinfo response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2UserInfoRaw fetches the UserInfo endpoint with custom query params
|
||||
// and no Authorization header (for testing that query/body tokens are rejected).
|
||||
func OAuth2UserInfoRaw(
|
||||
c *Client,
|
||||
queryParams url.Values,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
reqURL := oauth2BaseURL(c) + "/userinfo"
|
||||
if len(queryParams) > 0 {
|
||||
reqURL += "?" + queryParams.Encode()
|
||||
}
|
||||
|
||||
raw, err := getJSON(c.HTTPClient(), reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// OAuth2Introspect introspects a token using client credentials.
|
||||
func OAuth2Introspect(
|
||||
c *Client,
|
||||
clientID, clientSecret, token string,
|
||||
) (*OAuth2IntrospectResponse, *OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"token": {token},
|
||||
}
|
||||
|
||||
raw, err := postFormWithBasicAuth(
|
||||
c.HTTPClient(),
|
||||
oauth2BaseURL(c)+"/introspect",
|
||||
values,
|
||||
clientID,
|
||||
clientSecret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var result OAuth2IntrospectResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode introspect response: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2Revoke revokes a token using client credentials.
|
||||
func OAuth2Revoke(
|
||||
c *Client,
|
||||
clientID, clientSecret, token string,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
return OAuth2RevokeWithHint(c, clientID, clientSecret, token, "")
|
||||
}
|
||||
|
||||
// OAuth2RevokeWithHint revokes a token with an optional token_type_hint.
|
||||
func OAuth2RevokeWithHint(
|
||||
c *Client,
|
||||
clientID, clientSecret, token, tokenTypeHint string,
|
||||
) (*OAuth2HTTPResponse, error) {
|
||||
values := url.Values{
|
||||
"token": {token},
|
||||
}
|
||||
|
||||
if tokenTypeHint != "" {
|
||||
values.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
raw, err := postFormWithBasicAuth(
|
||||
c.HTTPClient(),
|
||||
oauth2BaseURL(c)+"/revoke",
|
||||
values,
|
||||
clientID,
|
||||
clientSecret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// PKCE helpers
|
||||
|
||||
// GeneratePKCE generates a code_verifier and code_challenge (S256) pair.
|
||||
func GeneratePKCE() (verifier, challenge string) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
|
||||
b := make([]byte, 64)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.IntN(len(charset))]
|
||||
}
|
||||
verifier = string(b)
|
||||
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||
|
||||
return verifier, challenge
|
||||
}
|
||||
|
||||
// IsConsentRedirect returns true when the authorize endpoint responded with
|
||||
// a 302 redirect to the consent page (as opposed to a redirect carrying an
|
||||
// authorization code).
|
||||
func IsConsentRedirect(resp *OAuth2HTTPResponse) bool {
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
return false
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Query().Get("consent_id") != ""
|
||||
}
|
||||
|
||||
// ExtractConsentIDFromResponse extracts the consent_id from an authorize
|
||||
// response. It handles the current redirect-based flow (302 to consent page)
|
||||
// as well as the legacy inline HTML flow (200 with hidden form field).
|
||||
func ExtractConsentIDFromResponse(resp *OAuth2HTTPResponse) (string, error) {
|
||||
if resp.StatusCode == http.StatusFound {
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("no Location header in redirect response")
|
||||
}
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse redirect url: %w", err)
|
||||
}
|
||||
consentID := u.Query().Get("consent_id")
|
||||
if consentID == "" {
|
||||
return "", fmt.Errorf("no consent_id in redirect url: %s", loc)
|
||||
}
|
||||
return consentID, nil
|
||||
}
|
||||
|
||||
return ExtractConsentID(resp.Body)
|
||||
}
|
||||
|
||||
// ExtractConsentID extracts the consent_id from a consent HTML page.
|
||||
func ExtractConsentID(body []byte) (string, error) {
|
||||
s := string(body)
|
||||
|
||||
needle := `name="consent_id" value="`
|
||||
idx := strings.Index(s, needle)
|
||||
if idx == -1 {
|
||||
return "", fmt.Errorf("consent_id not found in page")
|
||||
}
|
||||
|
||||
start := idx + len(needle)
|
||||
end := strings.Index(s[start:], `"`)
|
||||
if end == -1 {
|
||||
return "", fmt.Errorf("malformed consent_id value")
|
||||
}
|
||||
|
||||
return s[start : start+end], nil
|
||||
}
|
||||
|
||||
// OAuth2PerformAuthorizationCodeFlow performs the full authorization code flow
|
||||
// and returns the token response. This is a convenience function for tests that
|
||||
// need tokens but are not testing the authorization flow itself.
|
||||
func OAuth2PerformAuthorizationCodeFlow(
|
||||
t testing.TB,
|
||||
c *Client,
|
||||
clientID, clientSecret, redirectURI string,
|
||||
) *OAuth2TokenResponse {
|
||||
t.Helper()
|
||||
|
||||
verifier, challenge := GeneratePKCE()
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {"openid email profile offline_access"},
|
||||
"state": {"test-state"},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
|
||||
authResp, err := OAuth2Authorize(c, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
var code string
|
||||
if IsConsentRedirect(authResp) {
|
||||
consentID, err := ExtractConsentIDFromResponse(authResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
consentResp, err := OAuth2ConsentApprove(c, consentID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusFound, consentResp.StatusCode)
|
||||
|
||||
code, err = OAuth2AuthorizeCodeFromRedirect(consentResp)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Equal(t, http.StatusFound, authResp.StatusCode)
|
||||
code, err = OAuth2AuthorizeCodeFromRedirect(authResp)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
tokenResp, raw, err := OAuth2TokenWithCode(
|
||||
c,
|
||||
clientID,
|
||||
clientSecret,
|
||||
code,
|
||||
redirectURI,
|
||||
verifier,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, raw.StatusCode, "token exchange failed: %s", string(raw.Body))
|
||||
require.NotNil(t, tokenResp)
|
||||
|
||||
return tokenResp
|
||||
}
|
||||
@@ -15,7 +15,12 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -38,6 +43,7 @@ type TestEnv struct {
|
||||
BaseURL string
|
||||
cmd *exec.Cmd
|
||||
done chan error
|
||||
outputBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
func Setup() {
|
||||
@@ -64,6 +70,11 @@ func Setup() {
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureSigningKey("./testdata/oauth2_signing_key.pem"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: cannot create signing key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
testEnv = &TestEnv{
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
@@ -74,12 +85,16 @@ func Setup() {
|
||||
} else {
|
||||
cmd.Env = os.Environ()
|
||||
}
|
||||
if os.Getenv("PROBO_E2E_VERBOSE") != "" {
|
||||
|
||||
verbose := os.Getenv("PROBO_E2E_VERBOSE") != ""
|
||||
if verbose {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
var buf bytes.Buffer
|
||||
testEnv.outputBuf = &buf
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
}
|
||||
|
||||
testEnv.cmd = cmd
|
||||
@@ -100,18 +115,50 @@ func Setup() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := waitForServer(ctx, testEnv.BaseURL+"/api/console/v1/graphql", 30*time.Second); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: API server failed to start: %v\n", err)
|
||||
testEnv.dumpOutputOnFailure("API server failed to start", err)
|
||||
_ = testEnv.cmd.Process.Kill()
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := waitForServer(ctx, testEnv.MailpitBaseURL+"/api/v1/messages", 30*time.Second); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: MailPit server failed to start: %v\n", err)
|
||||
testEnv.dumpOutputOnFailure("MailPit server failed to start", err)
|
||||
_ = testEnv.cmd.Process.Kill()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !verbose {
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *TestEnv) dumpOutputOnFailure(context string, err error) {
|
||||
fmt.Fprintf(os.Stderr, "\n=== e2etest: %s: %v ===\n", context, err)
|
||||
|
||||
select {
|
||||
case waitErr := <-e.done:
|
||||
if waitErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: process exited with error: %v\n", waitErr)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: process exited cleanly (unexpected)\n")
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "e2etest: process is still running\n")
|
||||
}
|
||||
|
||||
if e.outputBuf != nil && e.outputBuf.Len() > 0 {
|
||||
output := e.outputBuf.Bytes()
|
||||
const maxTail = 10_000
|
||||
if len(output) > maxTail {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: (showing last %d bytes of output)\n", maxTail)
|
||||
output = output[len(output)-maxTail:]
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "--- probod output start ---\n%s\n--- probod output end ---\n", output)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: no captured output available\n")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForServer(ctx context.Context, url string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
@@ -120,6 +167,9 @@ func waitForServer(ctx context.Context, url string, timeout time.Duration) error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-testEnv.done:
|
||||
testEnv.done <- err
|
||||
return fmt.Errorf("process exited before becoming ready: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -131,14 +181,13 @@ func waitForServer(ctx context.Context, url string, timeout time.Duration) error
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
// Any response means server is up
|
||||
return nil
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Errorf("server did not become ready within %v", timeout)
|
||||
return fmt.Errorf("server at %s did not become ready within %v", url, timeout)
|
||||
}
|
||||
|
||||
func Teardown() {
|
||||
@@ -171,3 +220,31 @@ func GetMailpitBaseURL() string {
|
||||
}
|
||||
return testEnv.MailpitBaseURL
|
||||
}
|
||||
|
||||
// ensureSigningKey creates a 2048-bit RSA PEM key at path if it does not
|
||||
// already exist. The key is used exclusively for e2e test JWT signing.
|
||||
func ensureSigningKey(path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
data := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
|
||||
if err := os.MkdirAll("testdata", 0755); err != nil {
|
||||
return fmt.Errorf("cannot create testdata directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("cannot write key file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
15
go.mod
15
go.mod
@@ -54,17 +54,18 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
|
||||
30
go.sum
30
go.sum
@@ -70,20 +70,20 @@ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
@@ -92,8 +92,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
@@ -104,6 +104,8 @@ github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZ
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
@@ -226,8 +228,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,8 +33,22 @@ type (
|
||||
token string
|
||||
endpoint string
|
||||
httpClient *http.Client
|
||||
refresher *TokenRefresher
|
||||
}
|
||||
|
||||
// TokenRefresher holds the information needed to automatically refresh
|
||||
// an expired access token using the OAuth2 refresh_token grant.
|
||||
TokenRefresher struct {
|
||||
RefreshToken string
|
||||
TokenEndpoint string
|
||||
ClientID string
|
||||
// OnRefresh is called after a successful token refresh with the new
|
||||
// access token and refresh token so the caller can persist them.
|
||||
OnRefresh func(accessToken, refreshToken string) error
|
||||
}
|
||||
|
||||
Option func(*Client)
|
||||
|
||||
graphQLRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
@@ -47,15 +62,30 @@ type (
|
||||
graphQLError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
tokenRefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewClient(host string, token string, endpoint string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
func WithTokenRefresher(r *TokenRefresher) Option {
|
||||
return func(c *Client) { c.refresher = r }
|
||||
}
|
||||
|
||||
func NewClient(host string, token string, endpoint string, timeout time.Duration, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
host: host,
|
||||
token: token,
|
||||
endpoint: endpoint,
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Do(
|
||||
@@ -88,6 +118,42 @@ func (c *Client) DoRaw(
|
||||
query string,
|
||||
variables map[string]any,
|
||||
) ([]byte, error) {
|
||||
respBody, statusCode, err := c.doRequest(query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if statusCode == http.StatusUnauthorized && c.refresher != nil {
|
||||
if refreshErr := c.tryRefreshToken(); refreshErr == nil {
|
||||
respBody, statusCode, err = c.doRequest(query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
|
||||
case http.StatusForbidden:
|
||||
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"HTTP %d: %s",
|
||||
statusCode,
|
||||
string(respBody),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(
|
||||
query string,
|
||||
variables map[string]any,
|
||||
) ([]byte, int, error) {
|
||||
reqBody := graphQLRequest{
|
||||
Query: query,
|
||||
Variables: variables,
|
||||
@@ -95,7 +161,7 @@ func (c *Client) DoRaw(
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal GraphQL request: %w", err)
|
||||
return nil, 0, fmt.Errorf("cannot marshal GraphQL request: %w", err)
|
||||
}
|
||||
|
||||
host := c.host
|
||||
@@ -103,10 +169,10 @@ func (c *Client) DoRaw(
|
||||
host = "https://" + host
|
||||
}
|
||||
|
||||
url := host + c.endpoint
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
reqURL := host + c.endpoint
|
||||
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create HTTP request: %w", err)
|
||||
return nil, 0, fmt.Errorf("cannot create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -115,29 +181,65 @@ func (c *Client) DoRaw(
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot send HTTP request: %w", err)
|
||||
return nil, 0, fmt.Errorf("cannot send HTTP request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read HTTP response: %w", err)
|
||||
return nil, 0, fmt.Errorf("cannot read HTTP response: %w", err)
|
||||
}
|
||||
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) tryRefreshToken() error {
|
||||
r := c.refresher
|
||||
|
||||
values := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {r.ClientID},
|
||||
"refresh_token": {r.RefreshToken},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
r.TokenEndpoint,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot send refresh request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
|
||||
case http.StatusForbidden:
|
||||
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"HTTP %d: %s",
|
||||
resp.StatusCode,
|
||||
string(respBody),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("refresh token request failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
var token tokenRefreshResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return fmt.Errorf("cannot decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
c.token = token.AccessToken
|
||||
r.RefreshToken = token.RefreshToken
|
||||
|
||||
if r.OnRefresh != nil {
|
||||
return r.OnRefresh(token.AccessToken, token.RefreshToken)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,13 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const DefaultHTTPTimeout = 30 * time.Second
|
||||
const (
|
||||
DefaultHTTPTimeout = 30 * time.Second
|
||||
|
||||
// CLIClientID is the well-known OAuth2 client ID for the Probo CLI,
|
||||
// pre-provisioned in every Probo database via migration.
|
||||
CLIClientID = "AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
@@ -41,8 +47,10 @@ type (
|
||||
}
|
||||
|
||||
HostConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
Organization string `yaml:"organization"`
|
||||
Token string `yaml:"token"`
|
||||
RefreshToken string `yaml:"refresh_token,omitempty"`
|
||||
TokenEndpoint string `yaml:"token_endpoint,omitempty"`
|
||||
Organization string `yaml:"organization"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdCancel(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdClose(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -77,6 +77,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -93,6 +93,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -68,6 +68,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdStart(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -77,6 +77,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -98,6 +98,7 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -90,6 +90,7 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
decisions := make([]map[string]any, len(flagEntryIDs))
|
||||
|
||||
@@ -153,6 +153,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -103,6 +103,7 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -81,6 +81,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -76,6 +76,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -77,6 +77,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -77,6 +77,7 @@ func NewCmdAPI(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
endpoint,
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
var query string
|
||||
|
||||
@@ -102,6 +102,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -82,6 +82,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -15,74 +15,132 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cli/config"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/version"
|
||||
)
|
||||
|
||||
const (
|
||||
hostEU = "eu.console.getprobo.com"
|
||||
hostUS = "us.console.getprobo.com"
|
||||
|
||||
regionEU = "eu"
|
||||
regionUS = "us"
|
||||
regionCustom = "custom"
|
||||
)
|
||||
|
||||
type (
|
||||
oidcDiscovery struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
deviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
tokenErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagHost string
|
||||
flagToken string
|
||||
flagOrganization string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with a Probo host",
|
||||
Example: ` # Interactive login (prompts for hostname, token, and org)
|
||||
Example: ` # Interactive login (select region, opens browser for device authorization)
|
||||
prb auth login
|
||||
|
||||
# Non-interactive login
|
||||
prb auth login --hostname app.getprobo.com --token <token> --org <org-id>`,
|
||||
# Login to Probo EU
|
||||
prb auth login --hostname eu.console.getprobo.com
|
||||
|
||||
# Login to Probo US
|
||||
prb auth login --hostname us.console.getprobo.com
|
||||
|
||||
# Login to a self-hosted instance
|
||||
prb auth login --hostname probo.example.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if f.IOStreams.IsInteractive() {
|
||||
if flagHost == "" {
|
||||
if f.IOStreams.IsInteractive() && flagHost == "" {
|
||||
var region string
|
||||
|
||||
err := huh.NewSelect[string]().
|
||||
Title("Where is your Probo account hosted?").
|
||||
Options(
|
||||
huh.NewOption("Probo EU (eu.console.getprobo.com)", regionEU),
|
||||
huh.NewOption("Probo US (us.console.getprobo.com)", regionUS),
|
||||
huh.NewOption("Other (custom domain)", regionCustom),
|
||||
).
|
||||
Value(®ion).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch region {
|
||||
case regionEU:
|
||||
flagHost = hostEU
|
||||
case regionUS:
|
||||
flagHost = hostUS
|
||||
case regionCustom:
|
||||
err := huh.NewInput().
|
||||
Title("Probo hostname").
|
||||
Placeholder("app.getprobo.com").
|
||||
Placeholder("probo.example.com").
|
||||
Value(&flagHost).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if flagHost == "" {
|
||||
flagHost = "app.getprobo.com"
|
||||
}
|
||||
}
|
||||
|
||||
if flagToken == "" {
|
||||
err := huh.NewInput().
|
||||
Title("API token").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&flagToken).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if flagOrganization == "" {
|
||||
err := huh.NewInput().
|
||||
Title("Default organization ID").
|
||||
Placeholder("optional").
|
||||
Value(&flagOrganization).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("hostname is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flagHost == "" {
|
||||
flagHost = "app.getprobo.com"
|
||||
flagHost = hostEU
|
||||
}
|
||||
|
||||
if flagToken == "" {
|
||||
return fmt.Errorf("token is required; pass --token or run interactively")
|
||||
baseURL := normalizeHostToURL(flagHost)
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Discovering OAuth2 endpoints on %s...\n", flagHost)
|
||||
|
||||
discovery, err := fetchDiscovery(httpClient, baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot discover OAuth2 endpoints: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
@@ -90,9 +148,80 @@ func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceAuth, err := requestDeviceCode(
|
||||
httpClient,
|
||||
discovery.DeviceAuthorizationEndpoint,
|
||||
config.CLIClientID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start device authorization: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.ErrOut,
|
||||
"\nOpen the following URL in your browser and enter the code:\n\n %s\n\n Code: %s\n\n",
|
||||
deviceAuth.VerificationURI,
|
||||
deviceAuth.UserCode,
|
||||
)
|
||||
|
||||
if f.IOStreams.IsInteractive() {
|
||||
openBrowser(deviceAuth.VerificationURIComplete, cfg.Browser)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Waiting for authorization...")
|
||||
|
||||
token, err := pollForToken(
|
||||
httpClient,
|
||||
discovery.TokenEndpoint,
|
||||
config.CLIClientID,
|
||||
deviceAuth,
|
||||
)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
return fmt.Errorf("cannot complete device authorization: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
if f.IOStreams.IsInteractive() && flagOrganization == "" {
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut, "Loading organizations...")
|
||||
orgs, orgsErr := fetchOrganizations(baseURL, token.AccessToken)
|
||||
|
||||
if orgsErr == nil && len(orgs) > 0 {
|
||||
selected := orgs[0].ID
|
||||
options := make([]huh.Option[string], 0, len(orgs)+1)
|
||||
for _, org := range orgs {
|
||||
options = append(
|
||||
options,
|
||||
huh.NewOption(
|
||||
fmt.Sprintf("%s (%s)", org.Name, org.ID),
|
||||
org.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
options = append(
|
||||
options,
|
||||
huh.NewOption("Skip (no default)", ""),
|
||||
)
|
||||
|
||||
err = huh.NewSelect[string]().
|
||||
Title("Default organization").
|
||||
Value(&selected).
|
||||
Options(options...).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flagOrganization = selected
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Hosts[flagHost] = &config.HostConfig{
|
||||
Token: flagToken,
|
||||
Organization: flagOrganization,
|
||||
Token: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
TokenEndpoint: discovery.TokenEndpoint,
|
||||
Organization: flagOrganization,
|
||||
}
|
||||
cfg.ActiveHost = flagHost
|
||||
|
||||
@@ -110,9 +239,275 @@ func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagHost, "hostname", "", "Probo hostname (default: app.getprobo.com)")
|
||||
cmd.Flags().StringVar(&flagToken, "token", "", "API token")
|
||||
cmd.Flags().StringVar(&flagOrganization, "org", "", "Default organization ID")
|
||||
cmd.Flags().StringVar(
|
||||
&flagHost,
|
||||
"hostname",
|
||||
"",
|
||||
"Probo hostname (e.g. eu.console.getprobo.com, us.console.getprobo.com)",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&flagOrganization,
|
||||
"org",
|
||||
"",
|
||||
"Default organization ID",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func normalizeHostToURL(host string) string {
|
||||
lower := strings.ToLower(host)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return strings.TrimRight(host, "/")
|
||||
}
|
||||
return "https://" + strings.TrimRight(host, "/")
|
||||
}
|
||||
|
||||
func fetchDiscovery(client *http.Client, baseURL string) (*oidcDiscovery, error) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
baseURL+"/.well-known/openid-configuration",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch discovery document: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var discovery oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
if discovery.DeviceAuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("server does not support device authorization")
|
||||
}
|
||||
|
||||
if discovery.TokenEndpoint == "" {
|
||||
return nil, fmt.Errorf("server does not advertise a token endpoint")
|
||||
}
|
||||
|
||||
return &discovery, nil
|
||||
}
|
||||
|
||||
func requestDeviceCode(
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
clientID string,
|
||||
) (*deviceAuthResponse, error) {
|
||||
values := url.Values{
|
||||
"client_id": {clientID},
|
||||
"scope": {"openid profile email offline_access"},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
endpoint,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot request device code: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device authorization returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var deviceAuth deviceAuthResponse
|
||||
if err := json.Unmarshal(body, &deviceAuth); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode device authorization response: %w", err)
|
||||
}
|
||||
|
||||
return &deviceAuth, nil
|
||||
}
|
||||
|
||||
func pollForToken(
|
||||
client *http.Client,
|
||||
tokenEndpoint string,
|
||||
clientID string,
|
||||
deviceAuth *deviceAuthResponse,
|
||||
) (*tokenResponse, error) {
|
||||
interval := time.Duration(deviceAuth.Interval) * time.Second
|
||||
if interval < 1*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(deviceAuth.ExpiresIn) * time.Second)
|
||||
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
}
|
||||
|
||||
values := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceAuth.DeviceCode},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
tokenEndpoint,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot poll token endpoint: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var token tokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
var errResp tokenErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode error response: %w", err)
|
||||
}
|
||||
|
||||
switch errResp.Error {
|
||||
case "authorization_pending":
|
||||
continue
|
||||
case "slow_down":
|
||||
interval += 5 * time.Second
|
||||
continue
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("authorization denied by user")
|
||||
default:
|
||||
return nil, fmt.Errorf("token error: %s: %s", errResp.Error, errResp.ErrorDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewerOrganizationsQuery = `
|
||||
query($first: Int, $filter: ProfileFilter) {
|
||||
viewer {
|
||||
profiles(first: $first, filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type viewerOrganization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func fetchOrganizations(baseURL string, token string) ([]viewerOrganization, error) {
|
||||
client := api.NewClient(
|
||||
baseURL,
|
||||
token,
|
||||
"/api/connect/v1/graphql",
|
||||
config.DefaultHTTPTimeout,
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
"first": 100,
|
||||
"filter": map[string]any{
|
||||
"state": "ACTIVE",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := client.Do(viewerOrganizationsQuery, variables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch organizations: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Viewer struct {
|
||||
Profiles struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
Organization *viewerOrganization `json:"organization"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"profiles"`
|
||||
} `json:"viewer"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse organizations response: %w", err)
|
||||
}
|
||||
|
||||
orgs := make([]viewerOrganization, 0, len(resp.Viewer.Profiles.Edges))
|
||||
for _, edge := range resp.Viewer.Profiles.Edges {
|
||||
if edge.Node.Organization != nil {
|
||||
orgs = append(orgs, *edge.Node.Organization)
|
||||
}
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
func openBrowser(url, browser string) {
|
||||
if browser != "" {
|
||||
_ = exec.Command("sh", "-c", browser+" \"$0\"", url).Start()
|
||||
return
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
_ = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
_ = exec.Command(
|
||||
"rundll32",
|
||||
"url.dll,FileProtocolHandler",
|
||||
url,
|
||||
).Start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,26 @@
|
||||
package logout
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/config"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/version"
|
||||
)
|
||||
|
||||
type oidcDiscovery struct {
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
}
|
||||
|
||||
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
var flagHost string
|
||||
|
||||
@@ -66,10 +77,13 @@ func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := cfg.Hosts[flagHost]; !ok {
|
||||
hc, ok := cfg.Hosts[flagHost]
|
||||
if !ok {
|
||||
return fmt.Errorf("not logged in to %s", flagHost)
|
||||
}
|
||||
|
||||
revokeTokens(flagHost, hc, f)
|
||||
|
||||
delete(cfg.Hosts, flagHost)
|
||||
if cfg.ActiveHost == flagHost {
|
||||
cfg.ActiveHost = ""
|
||||
@@ -93,3 +107,106 @@ func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func revokeTokens(host string, hc *config.HostConfig, f *cmdutil.Factory) {
|
||||
baseURL := normalizeHostToURL(host)
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
discovery, err := fetchRevocationEndpoint(httpClient, baseURL)
|
||||
if err != nil || discovery.RevocationEndpoint == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if hc.RefreshToken != "" {
|
||||
_ = revokeToken(
|
||||
httpClient,
|
||||
discovery.RevocationEndpoint,
|
||||
hc.RefreshToken,
|
||||
"refresh_token",
|
||||
)
|
||||
}
|
||||
|
||||
if hc.Token != "" {
|
||||
_ = revokeToken(
|
||||
httpClient,
|
||||
discovery.RevocationEndpoint,
|
||||
hc.Token,
|
||||
"access_token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRevocationEndpoint(client *http.Client, baseURL string) (*oidcDiscovery, error) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
baseURL+"/.well-known/openid-configuration",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch discovery document: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var discovery oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
return &discovery, nil
|
||||
}
|
||||
|
||||
func revokeToken(
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
token string,
|
||||
tokenTypeHint string,
|
||||
) error {
|
||||
data := url.Values{
|
||||
"token": {token},
|
||||
"token_type_hint": {tokenTypeHint},
|
||||
"client_id": {config.CLIClientID},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
endpoint,
|
||||
strings.NewReader(data.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create revocation request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot send revocation request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("revocation endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeHostToURL(host string) string {
|
||||
lower := strings.ToLower(host)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return strings.TrimRight(host, "/")
|
||||
}
|
||||
return "https://" + strings.TrimRight(host, "/")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cli/config"
|
||||
"go.probo.inc/probo/pkg/cmd/iostreams"
|
||||
)
|
||||
@@ -24,3 +25,28 @@ type Factory struct {
|
||||
Version string
|
||||
Config func() (*config.Config, error)
|
||||
}
|
||||
|
||||
// TokenRefreshOption returns an api.Option that enables automatic access
|
||||
// token refresh using the stored OAuth2 refresh token. If the host config
|
||||
// has no refresh token or token endpoint, a no-op option is returned.
|
||||
func TokenRefreshOption(
|
||||
cfg *config.Config,
|
||||
host string,
|
||||
hc *config.HostConfig,
|
||||
) api.Option {
|
||||
if hc.RefreshToken == "" || hc.TokenEndpoint == "" {
|
||||
return func(*api.Client) {}
|
||||
}
|
||||
|
||||
return api.WithTokenRefresher(&api.TokenRefresher{
|
||||
RefreshToken: hc.RefreshToken,
|
||||
TokenEndpoint: hc.TokenEndpoint,
|
||||
ClientID: config.CLIClientID,
|
||||
OnRefresh: func(accessToken, refreshToken string) error {
|
||||
hc.Token = accessToken
|
||||
hc.RefreshToken = refreshToken
|
||||
cfg.Hosts[host] = hc
|
||||
return cfg.Save()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -115,6 +115,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -90,6 +90,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
implemented := "IMPLEMENTED"
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -97,6 +97,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -83,6 +83,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -75,6 +75,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -98,6 +98,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -105,6 +105,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -102,6 +102,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -99,6 +99,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -84,6 +84,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -111,6 +111,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -78,6 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -71,6 +71,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -77,6 +77,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -104,6 +104,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/connect/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -103,6 +103,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -102,6 +102,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -85,6 +85,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -95,6 +95,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -78,6 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -127,6 +127,7 @@ func NewCmdAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -96,6 +96,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -69,6 +69,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
|
||||
@@ -85,6 +85,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -99,6 +99,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -91,6 +91,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
|
||||
@@ -72,6 +72,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
_, err = client.Do(
|
||||
|
||||
@@ -86,6 +86,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{}
|
||||
|
||||
@@ -96,6 +96,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -78,6 +78,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -350,7 +350,7 @@ func (es *ElectronicSignature) computeSealV1() (string, error) {
|
||||
}
|
||||
|
||||
input := strings.Join(fields, "\n")
|
||||
return hash.SHA256Hex([]byte(input)), nil
|
||||
return hash.SHA256HexString(input), nil
|
||||
}
|
||||
|
||||
func ResetStaleCertificateProcessing(
|
||||
|
||||
@@ -102,6 +102,12 @@ const (
|
||||
CookieCategoryEntityType uint16 = 76
|
||||
CookieConsentRecordEntityType uint16 = 77
|
||||
CookieBannerVersionEntityType uint16 = 78
|
||||
OAuth2ClientEntityType uint16 = 79
|
||||
OAuth2ConsentEntityType uint16 = 80
|
||||
OAuth2AccessTokenEntityType uint16 = 81
|
||||
OAuth2RefreshTokenEntityType uint16 = 82
|
||||
OAuth2AuthorizationCodeEntityType uint16 = 83
|
||||
OAuth2DeviceCodeEntityType uint16 = 84
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -256,6 +262,18 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &CookieConsentRecord{ID: id}, true
|
||||
case CookieBannerVersionEntityType:
|
||||
return &CookieBannerVersion{ID: id}, true
|
||||
case OAuth2ClientEntityType:
|
||||
return &OAuth2Client{ID: id}, true
|
||||
case OAuth2ConsentEntityType:
|
||||
return &OAuth2Consent{ID: id}, true
|
||||
case OAuth2AccessTokenEntityType:
|
||||
return &OAuth2AccessToken{ID: id}, true
|
||||
case OAuth2RefreshTokenEntityType:
|
||||
return &OAuth2RefreshToken{ID: id}, true
|
||||
case OAuth2AuthorizationCodeEntityType:
|
||||
return &OAuth2AuthorizationCode{ID: id}, true
|
||||
case OAuth2DeviceCodeEntityType:
|
||||
return &OAuth2DeviceCode{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
107
pkg/coredata/migrations/20260406T112100Z.sql
Normal file
107
pkg/coredata/migrations/20260406T112100Z.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- 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.
|
||||
|
||||
-- OAuth2 Authorization Server tables
|
||||
|
||||
CREATE TYPE oauth2_client_visibility AS ENUM ('private', 'public');
|
||||
CREATE TYPE oauth2_client_token_endpoint_auth_method AS ENUM ('client_secret_basic', 'client_secret_post', 'none');
|
||||
CREATE TYPE oauth2_device_code_status AS ENUM ('pending', 'authorized', 'denied', 'expired');
|
||||
|
||||
CREATE TABLE iam_oauth2_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
client_secret_hash BYTEA,
|
||||
client_name TEXT NOT NULL,
|
||||
visibility oauth2_client_visibility NOT NULL,
|
||||
redirect_uris TEXT[] NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
grant_types TEXT[] NOT NULL,
|
||||
response_types TEXT[] NOT NULL,
|
||||
token_endpoint_auth_method oauth2_client_token_endpoint_auth_method NOT NULL,
|
||||
logo_uri TEXT,
|
||||
client_uri TEXT,
|
||||
contacts TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_authorization_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
nonce TEXT,
|
||||
auth_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_access_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
hashed_value BYTEA NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT iam_oauth2_access_tokens_hashed_value_unique UNIQUE (hashed_value)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
hashed_value BYTEA NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
access_token_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT iam_oauth2_refresh_tokens_hashed_value_unique UNIQUE (hashed_value)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_device_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_code_hash BYTEA NOT NULL,
|
||||
user_code TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
scopes TEXT[] NOT NULL,
|
||||
identity_id TEXT,
|
||||
status oauth2_device_code_status NOT NULL,
|
||||
last_polled_at TIMESTAMP WITH TIME ZONE,
|
||||
poll_interval INT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT iam_oauth2_device_codes_device_code_hash_unique UNIQUE (device_code_hash),
|
||||
CONSTRAINT iam_oauth2_device_codes_user_code_unique UNIQUE (user_code)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_consents (
|
||||
id TEXT PRIMARY KEY,
|
||||
identity_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
scopes TEXT[] NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
approved BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
2
pkg/coredata/migrations/20260406T112200Z.sql
Normal file
2
pkg/coredata/migrations/20260406T112200Z.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ADD COLUMN session_id TEXT NOT NULL REFERENCES iam_sessions(id);
|
||||
49
pkg/coredata/migrations/20260411T120000Z.sql
Normal file
49
pkg/coredata/migrations/20260411T120000Z.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- 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.
|
||||
|
||||
-- Allow system-level OAuth2 clients that don't belong to any tenant or
|
||||
-- organization (e.g. the Probo CLI).
|
||||
ALTER TABLE iam_oauth2_clients ALTER COLUMN tenant_id DROP NOT NULL;
|
||||
ALTER TABLE iam_oauth2_clients ALTER COLUMN organization_id DROP NOT NULL;
|
||||
|
||||
-- Well-known OAuth2 client for the Probo CLI (prb).
|
||||
-- This client is hardcoded in the CLI binary and used for the device
|
||||
-- authorization flow. Same pattern as GitHub CLI + GitHub Enterprise Server.
|
||||
INSERT INTO iam_oauth2_clients (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp',
|
||||
NULL,
|
||||
NULL,
|
||||
'Probo CLI',
|
||||
'public',
|
||||
'{}',
|
||||
'{openid,profile,email}',
|
||||
'{urn:ietf:params:oauth:grant-type:device_code,refresh_token}',
|
||||
'{code}',
|
||||
'none',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
20
pkg/coredata/migrations/20260413T232000Z.sql
Normal file
20
pkg/coredata/migrations/20260413T232000Z.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 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.
|
||||
|
||||
-- Add offline_access scope to the Probo CLI OAuth2 client so the device
|
||||
-- authorization flow can request refresh tokens.
|
||||
UPDATE iam_oauth2_clients
|
||||
SET scopes = '{openid,profile,email,offline_access}',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp';
|
||||
19
pkg/coredata/migrations/20260414T083800Z.sql
Normal file
19
pkg/coredata/migrations/20260414T083800Z.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 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.
|
||||
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ADD COLUMN IF NOT EXISTS device_code_id TEXT;
|
||||
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ALTER COLUMN redirect_uri DROP NOT NULL;
|
||||
17
pkg/coredata/migrations/20260414T140000Z.sql
Normal file
17
pkg/coredata/migrations/20260414T140000Z.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 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.
|
||||
|
||||
ALTER TABLE iam_oauth2_authorization_codes
|
||||
ADD COLUMN IF NOT EXISTS redeemed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS access_token_id TEXT;
|
||||
20
pkg/coredata/migrations/20260416T120000Z.sql
Normal file
20
pkg/coredata/migrations/20260416T120000Z.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 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.
|
||||
|
||||
ALTER TABLE iam_oauth2_authorization_codes
|
||||
ADD COLUMN IF NOT EXISTS hashed_value BYTEA;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS iam_oauth2_authorization_codes_hashed_value_unique
|
||||
ON iam_oauth2_authorization_codes (hashed_value)
|
||||
WHERE hashed_value IS NOT NULL;
|
||||
218
pkg/coredata/oauth2_access_token.go
Normal file
218
pkg/coredata/oauth2_access_token.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2AccessToken struct {
|
||||
ID gid.GID `db:"id"`
|
||||
HashedValue []byte `db:"hashed_value"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
)
|
||||
|
||||
func (t *OAuth2AccessToken) ExpiresIn(now time.Time) time.Duration {
|
||||
return t.ExpiresAt.Sub(now)
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_access_tokens (
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@hashed_value,
|
||||
@client_id,
|
||||
@identity_id,
|
||||
@scopes,
|
||||
@created_at,
|
||||
@expires_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": t.ID,
|
||||
"hashed_value": t.HashedValue,
|
||||
"client_id": t.ClientID,
|
||||
"identity_id": t.IdentityID,
|
||||
"scopes": t.Scopes,
|
||||
"created_at": t.CreatedAt,
|
||||
"expires_at": t.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) LoadByHashedValue(ctx context.Context, conn pg.Querier, hashedValue []byte) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_access_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"hashed_value": hashedValue})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AccessToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) LoadByHashedValueAndClientID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_access_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"hashed_value": hashedValue,
|
||||
"client_id": clientID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AccessToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) Delete(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": t.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_access_tokens: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) DeleteByClientAndIdentity(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
clientID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
client_id = @client_id
|
||||
AND identity_id = @identity_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"client_id": clientID,
|
||||
"identity_id": identityID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete oauth2_access_tokens by client and identity: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
217
pkg/coredata/oauth2_authorization_code.go
Normal file
217
pkg/coredata/oauth2_authorization_code.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type OAuth2AuthorizationCode struct {
|
||||
ID gid.GID `db:"id"`
|
||||
HashedValue []byte `db:"hashed_value"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
RedirectURI uri.URI `db:"redirect_uri"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
CodeChallenge *string `db:"code_challenge"`
|
||||
CodeChallengeMethod *OAuth2CodeChallengeMethod `db:"code_challenge_method"`
|
||||
Nonce *string `db:"nonce"`
|
||||
AuthTime time.Time `db:"auth_time"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
RedeemedAt *time.Time `db:"redeemed_at"`
|
||||
AccessTokenID *gid.GID `db:"access_token_id"`
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_authorization_codes (
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
auth_time,
|
||||
created_at,
|
||||
expires_at,
|
||||
redeemed_at,
|
||||
access_token_id
|
||||
) VALUES (
|
||||
@id,
|
||||
@hashed_value,
|
||||
@client_id,
|
||||
@identity_id,
|
||||
@redirect_uri,
|
||||
@scopes,
|
||||
@code_challenge,
|
||||
@code_challenge_method,
|
||||
@nonce,
|
||||
@auth_time,
|
||||
@created_at,
|
||||
@expires_at,
|
||||
@redeemed_at,
|
||||
@access_token_id
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"hashed_value": c.HashedValue,
|
||||
"client_id": c.ClientID,
|
||||
"identity_id": c.IdentityID,
|
||||
"redirect_uri": c.RedirectURI,
|
||||
"scopes": c.Scopes,
|
||||
"code_challenge": c.CodeChallenge,
|
||||
"code_challenge_method": c.CodeChallengeMethod,
|
||||
"nonce": c.Nonce,
|
||||
"auth_time": c.AuthTime,
|
||||
"created_at": c.CreatedAt,
|
||||
"expires_at": c.ExpiresAt,
|
||||
"redeemed_at": c.RedeemedAt,
|
||||
"access_token_id": c.AccessTokenID,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) LoadByHashForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
auth_time,
|
||||
created_at,
|
||||
expires_at,
|
||||
redeemed_at,
|
||||
access_token_id
|
||||
FROM
|
||||
iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{"hashed_value": hashedValue, "client_id": clientID},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AuthorizationCode])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
*c = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Redeem(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
now time.Time,
|
||||
accessTokenID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_authorization_codes
|
||||
SET
|
||||
redeemed_at = @redeemed_at,
|
||||
access_token_id = @access_token_id
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"redeemed_at": now,
|
||||
"access_token_id": accessTokenID,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot redeem oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
c.RedeemedAt = &now
|
||||
c.AccessTokenID = &accessTokenID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Delete(ctx context.Context, conn pg.Querier) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": c.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_authorization_codes: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
66
pkg/coredata/oauth2_claim.go
Normal file
66
pkg/coredata/oauth2_claim.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2Claim string
|
||||
|
||||
const (
|
||||
OAuth2ClaimIssuer OAuth2Claim = "iss"
|
||||
OAuth2ClaimSubject OAuth2Claim = "sub"
|
||||
OAuth2ClaimAudience OAuth2Claim = "aud"
|
||||
OAuth2ClaimExpiration OAuth2Claim = "exp"
|
||||
OAuth2ClaimIssuedAt OAuth2Claim = "iat"
|
||||
OAuth2ClaimAuthTime OAuth2Claim = "auth_time"
|
||||
OAuth2ClaimNonce OAuth2Claim = "nonce"
|
||||
OAuth2ClaimAtHash OAuth2Claim = "at_hash"
|
||||
OAuth2ClaimEmail OAuth2Claim = "email"
|
||||
OAuth2ClaimEmailVerified OAuth2Claim = "email_verified"
|
||||
OAuth2ClaimName OAuth2Claim = "name"
|
||||
)
|
||||
|
||||
func (c OAuth2Claim) IsValid() bool {
|
||||
switch c {
|
||||
case OAuth2ClaimIssuer,
|
||||
OAuth2ClaimSubject,
|
||||
OAuth2ClaimAudience,
|
||||
OAuth2ClaimExpiration,
|
||||
OAuth2ClaimIssuedAt,
|
||||
OAuth2ClaimAuthTime,
|
||||
OAuth2ClaimNonce,
|
||||
OAuth2ClaimAtHash,
|
||||
OAuth2ClaimEmail,
|
||||
OAuth2ClaimEmailVerified,
|
||||
OAuth2ClaimName:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c OAuth2Claim) String() string { return string(c) }
|
||||
|
||||
func (c *OAuth2Claim) UnmarshalText(text []byte) error {
|
||||
*c = OAuth2Claim(text)
|
||||
if !c.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2Claim", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c OAuth2Claim) MarshalText() ([]byte, error) {
|
||||
return []byte(c.String()), nil
|
||||
}
|
||||
384
pkg/coredata/oauth2_client.go
Normal file
384
pkg/coredata/oauth2_client.go
Normal file
@@ -0,0 +1,384 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2Client struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID *gid.GID `db:"organization_id"`
|
||||
ClientSecretHash []byte `db:"client_secret_hash"`
|
||||
ClientName string `db:"client_name"`
|
||||
Visibility OAuth2ClientVisibility `db:"visibility"`
|
||||
RedirectURIs []uri.URI `db:"redirect_uris"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
GrantTypes OAuth2GrantTypes `db:"grant_types"`
|
||||
ResponseTypes OAuth2ResponseTypes `db:"response_types"`
|
||||
TokenEndpointAuthMethod OAuth2ClientTokenEndpointAuthMethod `db:"token_endpoint_auth_method"`
|
||||
LogoURI *uri.URI `db:"logo_uri"`
|
||||
ClientURI *uri.URI `db:"client_uri"`
|
||||
Contacts []string `db:"contacts"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
OAuth2Clients []*OAuth2Client
|
||||
)
|
||||
|
||||
func (c *OAuth2Client) IsRedirectURIAllowed(rawURI string) bool {
|
||||
return slices.Contains(c.RedirectURIs, uri.URI(rawURI))
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) HasGrantType(grantType OAuth2GrantType) bool {
|
||||
return slices.Contains(c.GrantTypes, grantType)
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) AreScopesAllowed(scopes OAuth2Scopes) bool {
|
||||
return c.Scopes.ContainsAll(scopes.Values())
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) CursorKey(orderBy OAuth2ClientOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(c.ID, c.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var organizationID *gid.GID
|
||||
if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query oauth2 client authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
attrs := make(map[string]string)
|
||||
if organizationID != nil {
|
||||
attrs["organization_id"] = organizationID.String()
|
||||
}
|
||||
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": clientID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query iam_oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
client, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Client])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
*c = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Clients) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[OAuth2ClientOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(
|
||||
q,
|
||||
scope.SQLFragment(),
|
||||
cursor.SQLFragment(),
|
||||
)
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query iam_oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
clients, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[OAuth2Client])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
*c = clients
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Clients) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_clients (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@client_secret_hash,
|
||||
@client_name,
|
||||
@visibility,
|
||||
@redirect_uris,
|
||||
@scopes,
|
||||
@grant_types,
|
||||
@response_types,
|
||||
@token_endpoint_auth_method,
|
||||
@logo_uri,
|
||||
@client_uri,
|
||||
@contacts,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": c.OrganizationID,
|
||||
"client_secret_hash": c.ClientSecretHash,
|
||||
"client_name": c.ClientName,
|
||||
"visibility": c.Visibility,
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"grant_types": c.GrantTypes,
|
||||
"response_types": c.ResponseTypes,
|
||||
"token_endpoint_auth_method": c.TokenEndpointAuthMethod,
|
||||
"logo_uri": c.LogoURI,
|
||||
"client_uri": c.ClientURI,
|
||||
"contacts": c.Contacts,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_clients
|
||||
SET
|
||||
client_name = @client_name,
|
||||
visibility = @visibility,
|
||||
redirect_uris = @redirect_uris,
|
||||
scopes = @scopes,
|
||||
grant_types = @grant_types,
|
||||
response_types = @response_types,
|
||||
token_endpoint_auth_method = @token_endpoint_auth_method,
|
||||
logo_uri = @logo_uri,
|
||||
client_uri = @client_uri,
|
||||
contacts = @contacts,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"client_name": c.ClientName,
|
||||
"visibility": c.Visibility,
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"grant_types": c.GrantTypes,
|
||||
"response_types": c.ResponseTypes,
|
||||
"token_endpoint_auth_method": c.TokenEndpointAuthMethod,
|
||||
"logo_uri": c.LogoURI,
|
||||
"client_uri": c.ClientURI,
|
||||
"contacts": c.Contacts,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": c.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
56
pkg/coredata/oauth2_client_order_field.go
Normal file
56
pkg/coredata/oauth2_client_order_field.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientOrderField string
|
||||
|
||||
const (
|
||||
OAuth2ClientOrderFieldCreatedAt OAuth2ClientOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (f OAuth2ClientOrderField) Column() string {
|
||||
switch f {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", f))
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) IsValid() bool {
|
||||
switch f {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) String() string {
|
||||
return string(f)
|
||||
}
|
||||
|
||||
func (f *OAuth2ClientOrderField) UnmarshalText(text []byte) error {
|
||||
*f = OAuth2ClientOrderField(text)
|
||||
if !f.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(f.String()), nil
|
||||
}
|
||||
51
pkg/coredata/oauth2_client_token_endpoint_auth_method.go
Normal file
51
pkg/coredata/oauth2_client_token_endpoint_auth_method.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientTokenEndpointAuthMethod string
|
||||
|
||||
const (
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretBasic OAuth2ClientTokenEndpointAuthMethod = "client_secret_basic"
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretPost OAuth2ClientTokenEndpointAuthMethod = "client_secret_post"
|
||||
OAuth2ClientTokenEndpointAuthMethodNone OAuth2ClientTokenEndpointAuthMethod = "none"
|
||||
)
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) IsValid() bool {
|
||||
switch m {
|
||||
case OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
OAuth2ClientTokenEndpointAuthMethodNone:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) String() string { return string(m) }
|
||||
|
||||
func (m *OAuth2ClientTokenEndpointAuthMethod) UnmarshalText(text []byte) error {
|
||||
*m = OAuth2ClientTokenEndpointAuthMethod(text)
|
||||
if !m.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientTokenEndpointAuthMethod", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) MarshalText() ([]byte, error) {
|
||||
return []byte(m.String()), nil
|
||||
}
|
||||
48
pkg/coredata/oauth2_client_visibility.go
Normal file
48
pkg/coredata/oauth2_client_visibility.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientVisibility string
|
||||
|
||||
const (
|
||||
OAuth2ClientVisibilityPrivate OAuth2ClientVisibility = "private"
|
||||
OAuth2ClientVisibilityPublic OAuth2ClientVisibility = "public"
|
||||
)
|
||||
|
||||
func (v OAuth2ClientVisibility) IsValid() bool {
|
||||
switch v {
|
||||
case OAuth2ClientVisibilityPrivate, OAuth2ClientVisibilityPublic:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v OAuth2ClientVisibility) String() string { return string(v) }
|
||||
|
||||
func (v *OAuth2ClientVisibility) UnmarshalText(text []byte) error {
|
||||
*v = OAuth2ClientVisibility(text)
|
||||
if !v.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientVisibility", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v OAuth2ClientVisibility) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
47
pkg/coredata/oauth2_code_challenge_method.go
Normal file
47
pkg/coredata/oauth2_code_challenge_method.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2CodeChallengeMethod string
|
||||
|
||||
const (
|
||||
OAuth2CodeChallengeMethodS256 OAuth2CodeChallengeMethod = "S256"
|
||||
)
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) IsValid() bool {
|
||||
switch m {
|
||||
case OAuth2CodeChallengeMethodS256:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) String() string { return string(m) }
|
||||
|
||||
func (m *OAuth2CodeChallengeMethod) UnmarshalText(text []byte) error {
|
||||
*m = OAuth2CodeChallengeMethod(text)
|
||||
if !m.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2CodeChallengeMethod", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) MarshalText() ([]byte, error) {
|
||||
return []byte(m.String()), nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user