From 11770b40586e7d7fec8c04fcbe9f018b9781dafc Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 30 Mar 2026 14:49:18 +0200 Subject: [PATCH] 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 --- .../src/pages/iam/auth/ConsentPage.tsx | 306 ++ .../src/pages/iam/auth/ConsentPageLoader.tsx | 77 + .../pages/iam/auth/DeviceActivationPage.tsx | 224 ++ .../iam/auth/DeviceActivationPageLoader.tsx | 37 + apps/console/src/routes.tsx | 14 + e2e/console/oauth2_test.go | 3385 +++++++++++++++++ e2e/console/testdata/config.yaml | 9 + e2e/internal/factory/factory.go | 61 + e2e/internal/testutil/client.go | 34 + e2e/internal/testutil/oauth2.go | 909 +++++ e2e/internal/testutil/testutil.go | 91 +- go.mod | 15 +- go.sum | 30 +- pkg/cli/api/client.go | 144 +- pkg/cli/config/config.go | 14 +- .../campaign/addsource/addsource.go | 1 + .../access-review/campaign/cancel/cancel.go | 1 + pkg/cmd/access-review/campaign/close/close.go | 1 + .../access-review/campaign/create/create.go | 1 + .../access-review/campaign/delete/delete.go | 1 + pkg/cmd/access-review/campaign/list/list.go | 1 + .../campaign/removesource/removesource.go | 1 + pkg/cmd/access-review/campaign/start/start.go | 1 + .../access-review/campaign/update/update.go | 1 + pkg/cmd/access-review/campaign/view/view.go | 1 + pkg/cmd/access-review/entry/decide/decide.go | 1 + .../entry/decideall/decideall.go | 1 + pkg/cmd/access-review/entry/list/list.go | 1 + pkg/cmd/access-review/entry/setflag/flag.go | 1 + pkg/cmd/access-review/source/create/create.go | 1 + pkg/cmd/access-review/source/delete/delete.go | 1 + pkg/cmd/access-review/source/list/list.go | 1 + pkg/cmd/access-review/source/update/update.go | 1 + pkg/cmd/access-review/source/view/view.go | 1 + pkg/cmd/api/api.go | 1 + pkg/cmd/auditlog/list/list.go | 1 + pkg/cmd/auditlog/view/view.go | 1 + pkg/cmd/auth/login/login.go | 471 ++- pkg/cmd/auth/logout/logout.go | 119 +- pkg/cmd/cmdutil/factory.go | 26 + pkg/cmd/context/get/get.go | 1 + pkg/cmd/context/update/update.go | 1 + pkg/cmd/control/create/create.go | 1 + pkg/cmd/control/delete/delete.go | 1 + pkg/cmd/control/list/list.go | 1 + pkg/cmd/control/update/update.go | 1 + pkg/cmd/control/view/view.go | 1 + pkg/cmd/evidence/delete/delete.go | 1 + pkg/cmd/evidence/list/list.go | 1 + pkg/cmd/evidence/view/view.go | 1 + pkg/cmd/finding/create/create.go | 1 + pkg/cmd/finding/delete/delete.go | 1 + pkg/cmd/finding/list/list.go | 1 + pkg/cmd/finding/update/update.go | 1 + pkg/cmd/finding/view/view.go | 1 + pkg/cmd/framework/create/create.go | 1 + pkg/cmd/framework/delete/delete.go | 1 + pkg/cmd/framework/list/list.go | 1 + pkg/cmd/framework/update/update.go | 1 + pkg/cmd/framework/view/view.go | 1 + pkg/cmd/org/list/list.go | 1 + pkg/cmd/risk/create/create.go | 1 + pkg/cmd/risk/delete/delete.go | 1 + pkg/cmd/risk/list/list.go | 1 + pkg/cmd/risk/update/update.go | 1 + pkg/cmd/risk/view/view.go | 1 + pkg/cmd/soa/create/create.go | 1 + pkg/cmd/soa/delete/delete.go | 1 + pkg/cmd/soa/list/list.go | 1 + pkg/cmd/soa/statement/add/add.go | 1 + pkg/cmd/soa/statement/list/list.go | 1 + pkg/cmd/soa/statement/remove/remove.go | 1 + pkg/cmd/soa/statement/update/update.go | 1 + pkg/cmd/soa/update/update.go | 1 + pkg/cmd/soa/view/view.go | 1 + pkg/cmd/user/list/list.go | 1 + pkg/cmd/user/view/view.go | 1 + pkg/cmd/webhook/create/create.go | 1 + pkg/cmd/webhook/delete/delete.go | 1 + pkg/cmd/webhook/event/list/list.go | 1 + pkg/cmd/webhook/list/list.go | 1 + pkg/cmd/webhook/update/update.go | 1 + pkg/cmd/webhook/view/view.go | 1 + pkg/coredata/electronic_signature.go | 2 +- pkg/coredata/entity_type_reg.go | 18 + pkg/coredata/migrations/20260406T112100Z.sql | 107 + pkg/coredata/migrations/20260406T112200Z.sql | 2 + pkg/coredata/migrations/20260411T120000Z.sql | 49 + pkg/coredata/migrations/20260413T232000Z.sql | 20 + pkg/coredata/migrations/20260414T083800Z.sql | 19 + pkg/coredata/migrations/20260414T140000Z.sql | 17 + pkg/coredata/migrations/20260416T120000Z.sql | 20 + pkg/coredata/oauth2_access_token.go | 218 ++ pkg/coredata/oauth2_authorization_code.go | 217 ++ pkg/coredata/oauth2_claim.go | 66 + pkg/coredata/oauth2_client.go | 384 ++ pkg/coredata/oauth2_client_order_field.go | 56 + ...auth2_client_token_endpoint_auth_method.go | 51 + pkg/coredata/oauth2_client_visibility.go | 48 + pkg/coredata/oauth2_code_challenge_method.go | 47 + pkg/coredata/oauth2_consent.go | 497 +++ pkg/coredata/oauth2_consent_order_field.go | 56 + pkg/coredata/oauth2_device_code.go | 308 ++ pkg/coredata/oauth2_device_code_status.go | 53 + pkg/coredata/oauth2_device_code_test.go | 66 + pkg/coredata/oauth2_grant_type.go | 54 + pkg/coredata/oauth2_refresh_token.go | 344 ++ pkg/coredata/oauth2_response_type.go | 50 + pkg/coredata/oauth2_scope.go | 119 + pkg/coredata/oauth2_scope_test.go | 143 + pkg/coredata/oauth2_signing_algorithm.go | 47 + pkg/coredata/oauth2_subject_type.go | 47 + pkg/coredata/oauth2_token_type_hint.go | 49 + pkg/coredata/webhook_subscription.go | 9 +- pkg/crypto/hash/hash.go | 16 +- pkg/crypto/jose/jose.go | 98 + pkg/crypto/jose/jose_test.go | 288 ++ pkg/crypto/rand/rand.go | 73 + pkg/gid/gid.go | 10 + pkg/iam/auth_service.go | 5 +- pkg/iam/authorizer.go | 4 + pkg/iam/iam_actions.go | 4 + pkg/iam/iam_policies.go | 17 + pkg/iam/oauth2server/errors.go | 110 + pkg/iam/oauth2server/gc.go | 126 + pkg/iam/oauth2server/id_token.go | 108 + pkg/iam/oauth2server/id_token_test.go | 300 ++ pkg/iam/oauth2server/metadata.go | 123 + pkg/iam/oauth2server/metadata_test.go | 240 ++ pkg/iam/oauth2server/pkce.go | 43 + pkg/iam/oauth2server/pkce_test.go | 158 + pkg/iam/oauth2server/service.go | 1675 ++++++++ pkg/iam/policy_set.go | 1 + pkg/iam/scim/service.go | 14 +- pkg/iam/service.go | 23 + pkg/llm/registry_gen.go | 198 +- pkg/net/net.go | 30 + pkg/net/net_test.go | 84 + pkg/probod/auth_config.go | 15 + pkg/probod/probod.go | 68 + pkg/server/api/api.go | 7 + .../authn/oauth2_access_token_middleware.go | 59 + pkg/server/api/connect/v1/base_resolvers.go | 16 + pkg/server/api/connect/v1/cache.go | 31 + pkg/server/api/connect/v1/gqlgen.yaml | 2 +- .../api/connect/v1/graphql/oauth2.graphql | 42 + pkg/server/api/connect/v1/oauth2_error.go | 120 + pkg/server/api/connect/v1/oauth2_handler.go | 547 +++ pkg/server/api/connect/v1/oauth2_resolvers.go | 126 + pkg/server/api/connect/v1/resolver.go | 26 +- pkg/server/api/connect/v1/types/oauth2.go | 330 ++ pkg/server/api/console/v1/resolver.go | 1 + pkg/server/server.go | 30 + pkg/uri/uri.go | 73 + pkg/uri/uri_test.go | 251 ++ 155 files changed, 14483 insertions(+), 223 deletions(-) create mode 100644 apps/console/src/pages/iam/auth/ConsentPage.tsx create mode 100644 apps/console/src/pages/iam/auth/ConsentPageLoader.tsx create mode 100644 apps/console/src/pages/iam/auth/DeviceActivationPage.tsx create mode 100644 apps/console/src/pages/iam/auth/DeviceActivationPageLoader.tsx create mode 100644 e2e/console/oauth2_test.go create mode 100644 e2e/internal/testutil/oauth2.go create mode 100644 pkg/coredata/migrations/20260406T112100Z.sql create mode 100644 pkg/coredata/migrations/20260406T112200Z.sql create mode 100644 pkg/coredata/migrations/20260411T120000Z.sql create mode 100644 pkg/coredata/migrations/20260413T232000Z.sql create mode 100644 pkg/coredata/migrations/20260414T083800Z.sql create mode 100644 pkg/coredata/migrations/20260414T140000Z.sql create mode 100644 pkg/coredata/migrations/20260416T120000Z.sql create mode 100644 pkg/coredata/oauth2_access_token.go create mode 100644 pkg/coredata/oauth2_authorization_code.go create mode 100644 pkg/coredata/oauth2_claim.go create mode 100644 pkg/coredata/oauth2_client.go create mode 100644 pkg/coredata/oauth2_client_order_field.go create mode 100644 pkg/coredata/oauth2_client_token_endpoint_auth_method.go create mode 100644 pkg/coredata/oauth2_client_visibility.go create mode 100644 pkg/coredata/oauth2_code_challenge_method.go create mode 100644 pkg/coredata/oauth2_consent.go create mode 100644 pkg/coredata/oauth2_consent_order_field.go create mode 100644 pkg/coredata/oauth2_device_code.go create mode 100644 pkg/coredata/oauth2_device_code_status.go create mode 100644 pkg/coredata/oauth2_device_code_test.go create mode 100644 pkg/coredata/oauth2_grant_type.go create mode 100644 pkg/coredata/oauth2_refresh_token.go create mode 100644 pkg/coredata/oauth2_response_type.go create mode 100644 pkg/coredata/oauth2_scope.go create mode 100644 pkg/coredata/oauth2_scope_test.go create mode 100644 pkg/coredata/oauth2_signing_algorithm.go create mode 100644 pkg/coredata/oauth2_subject_type.go create mode 100644 pkg/coredata/oauth2_token_type_hint.go create mode 100644 pkg/crypto/jose/jose.go create mode 100644 pkg/crypto/jose/jose_test.go create mode 100644 pkg/crypto/rand/rand.go create mode 100644 pkg/iam/oauth2server/errors.go create mode 100644 pkg/iam/oauth2server/gc.go create mode 100644 pkg/iam/oauth2server/id_token.go create mode 100644 pkg/iam/oauth2server/id_token_test.go create mode 100644 pkg/iam/oauth2server/metadata.go create mode 100644 pkg/iam/oauth2server/metadata_test.go create mode 100644 pkg/iam/oauth2server/pkce.go create mode 100644 pkg/iam/oauth2server/pkce_test.go create mode 100644 pkg/iam/oauth2server/service.go create mode 100644 pkg/net/net.go create mode 100644 pkg/net/net_test.go create mode 100644 pkg/server/api/authn/oauth2_access_token_middleware.go create mode 100644 pkg/server/api/connect/v1/cache.go create mode 100644 pkg/server/api/connect/v1/graphql/oauth2.graphql create mode 100644 pkg/server/api/connect/v1/oauth2_error.go create mode 100644 pkg/server/api/connect/v1/oauth2_handler.go create mode 100644 pkg/server/api/connect/v1/oauth2_resolvers.go create mode 100644 pkg/server/api/connect/v1/types/oauth2.go create mode 100644 pkg/uri/uri.go create mode 100644 pkg/uri/uri_test.go diff --git a/apps/console/src/pages/iam/auth/ConsentPage.tsx b/apps/console/src/pages/iam/auth/ConsentPage.tsx new file mode 100644 index 000000000..efe719543 --- /dev/null +++ b/apps/console/src/pages/iam/auth/ConsentPage.tsx @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 = { + 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 ( + + + + ); + case "email": + return ( + + + + ); + case "profile": + return ( + + + + ); + case "offline_access": + return ( + + + + ); + default: + return null; + } +} + +export default function ConsentPage(props: { + queryRef: PreloadedQuery; +}) { + 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(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 ( +
+

{__("Invalid Request")}

+

+ {__("This consent request is invalid or has expired.")} +

+
+ ); + } + + if (deviceResult === "authorized") { + return ( +
+

{__("Device Authorized")}

+

+ {__("Your device has been successfully authorized. You can close this window and return to your device.")} +

+
+ ); + } + + if (deviceResult === "denied") { + return ( +
+

{__("Access Denied")}

+

+ {__("You have denied the authorization request. You can close this window.")} +

+
+ ); + } + + return ( +
+
+
+
+ + + +
+
+

+ {__("Authorize")} + {" "} + {consent.application.name} +

+

+ {__( + "This application is requesting access to your account with the following permissions:", + )} +

+
+ +
    + {consent.scopes.map((scope: string) => { + const label = scopeLabels[scope]; + if (!label) return null; + return ( +
  • + + {__(label)} +
  • + ); + })} +
+ +
+ + +
+ +

+ {__("You can revoke access at any time from your account settings.")} +

+
+ ); +} diff --git a/apps/console/src/pages/iam/auth/ConsentPageLoader.tsx b/apps/console/src/pages/iam/auth/ConsentPageLoader.tsx new file mode 100644 index 000000000..ecea77893 --- /dev/null +++ b/apps/console/src/pages/iam/auth/ConsentPageLoader.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + loadQuery({ consentId }); + }, [loadQuery, consentId]); + + if (!queryRef) return null; + + return ; +} + +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 ( +
+

{__("Invalid Request")}

+

+ {__("This consent request is invalid or has expired.")} +

+
+ ); +} + +export default function ConsentPageLoader() { + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/iam/auth/DeviceActivationPage.tsx b/apps/console/src/pages/iam/auth/DeviceActivationPage.tsx new file mode 100644 index 000000000..6578bad38 --- /dev/null +++ b/apps/console/src/pages/iam/auth/DeviceActivationPage.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; +}) { + 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(() => { + 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(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) => { + 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) => { + 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 ( +
+

{__("Device Authorized")}

+

+ {__("Your device has been successfully authorized. You can close this window and return to your device.")} +

+
+ ); + } + + return ( +
+
+

{__("Device Activation")}

+

+ {__("Enter the code displayed on your device")} +

+
+ +
void handleSubmit(e)} className="space-y-6"> +
+ {values.map((val, idx) => ( +
+ {idx === 4 && ( + + )} + { 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" + /> +
+ ))} +
+ + +
+ +

+ {__("Make sure this code matches the one on your device.")} +

+
+ ); +} diff --git a/apps/console/src/pages/iam/auth/DeviceActivationPageLoader.tsx b/apps/console/src/pages/iam/auth/DeviceActivationPageLoader.tsx new file mode 100644 index 000000000..d73141afb --- /dev/null +++ b/apps/console/src/pages/iam/auth/DeviceActivationPageLoader.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + loadQuery({}); + }, [loadQuery]); + + if (!queryRef) return null; + + return ; +} + +export default function DeviceActivationPageLoader() { + return ; +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 970b6a858..37014c844 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -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"), + ), + }, ], }, { diff --git a/e2e/console/oauth2_test.go b/e2e/console/oauth2_test.go new file mode 100644 index 000000000..8bd2e7535 --- /dev/null +++ b/e2e/console/oauth2_test.go @@ -0,0 +1,3385 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 console_test + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +// --------------------------------------------------------------------------- +// 1. Discovery and JWKS +// --------------------------------------------------------------------------- + +func TestOAuth2_Discovery(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + discovery, raw, err := testutil.OAuth2Discovery(owner) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, discovery) + + assert.NotEmpty(t, discovery.Issuer) + assert.Contains(t, discovery.AuthorizationEndpoint, "/oauth2/authorize") + assert.Contains(t, discovery.TokenEndpoint, "/oauth2/token") + assert.Contains(t, discovery.UserinfoEndpoint, "/oauth2/userinfo") + assert.Contains(t, discovery.JwksURI, "/oauth2/jwks") + assert.Contains(t, discovery.RegistrationEndpoint, "/oauth2/register") + assert.Contains(t, discovery.IntrospectionEndpoint, "/oauth2/introspect") + assert.Contains(t, discovery.RevocationEndpoint, "/oauth2/revoke") + assert.Contains(t, discovery.DeviceAuthorizationEndpoint, "/oauth2/device") + + assert.Contains(t, discovery.GrantTypesSupported, "authorization_code") + assert.Contains(t, discovery.GrantTypesSupported, "refresh_token") + assert.Contains(t, discovery.GrantTypesSupported, "urn:ietf:params:oauth:grant-type:device_code") + + assert.Contains(t, discovery.ScopesSupported, "openid") + assert.Contains(t, discovery.ScopesSupported, "profile") + assert.Contains(t, discovery.ScopesSupported, "email") + assert.Contains(t, discovery.ScopesSupported, "offline_access") + + assert.Contains(t, discovery.ResponseTypesSupported, "code") + assert.Contains(t, discovery.CodeChallengeMethodsSupported, "S256") + + assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_basic") + assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_post") + assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "none") + + assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "client_secret_basic") + assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "client_secret_post") + assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "none") + + assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "client_secret_basic") + assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "client_secret_post") + assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "none") + + assert.Contains(t, discovery.SubjectTypesSupported, "public") + assert.Contains(t, discovery.IDTokenSigningAlgValuesSupported, "RS256") + + assert.Contains(t, discovery.ClaimsSupported, "iss") + assert.Contains(t, discovery.ClaimsSupported, "sub") + assert.Contains(t, discovery.ClaimsSupported, "aud") + assert.Contains(t, discovery.ClaimsSupported, "exp") + assert.Contains(t, discovery.ClaimsSupported, "iat") + assert.Contains(t, discovery.ClaimsSupported, "auth_time") + assert.Contains(t, discovery.ClaimsSupported, "nonce") + assert.Contains(t, discovery.ClaimsSupported, "at_hash") + assert.Contains(t, discovery.ClaimsSupported, "email") + assert.Contains(t, discovery.ClaimsSupported, "email_verified") + assert.Contains(t, discovery.ClaimsSupported, "name") +} + +func TestOAuth2_JWKS(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + jwks, raw, err := testutil.OAuth2JWKS(owner) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, jwks) + + require.NotEmpty(t, jwks.Keys, "JWKS must contain at least one key") + + key := jwks.Keys[0] + assert.Equal(t, "RSA", key["kty"]) + assert.NotEmpty(t, key["kid"]) + assert.NotEmpty(t, key["n"]) + assert.NotEmpty(t, key["e"]) + assert.Equal(t, "sig", key["use"]) + assert.Equal(t, "RS256", key["alg"]) +} + +// --------------------------------------------------------------------------- +// 2. Dynamic Client Registration +// --------------------------------------------------------------------------- + +func TestOAuth2_RegisterClient(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "happy path with confidential client", + func(t *testing.T) { + t.Parallel() + + result := factory.CreateOAuth2Client(owner, nil) + assert.NotEmpty(t, result.ClientID) + assert.NotEmpty(t, result.ClientSecret) + }, + ) + + t.Run( + "public client has no secret", + func(t *testing.T) { + t.Parallel() + + result := factory.CreatePublicOAuth2Client(owner, nil) + assert.NotEmpty(t, result.ClientID) + assert.Empty(t, result.ClientSecret) + }, + ) + + t.Run( + "invalid redirect URI for private client", + func(t *testing.T) { + t.Parallel() + + _, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "client_name": factory.SafeName("Bad Redirect"), + "visibility": "private", + "redirect_uris": []string{"http://evil.example.com/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "scopes": "openid", + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, raw.StatusCode) + }, + ) + + t.Run( + "non-member cannot register", + func(t *testing.T) { + t.Parallel() + + otherOwner := testutil.NewClient(t, testutil.RoleOwner) + + _, raw, err := testutil.OAuth2RegisterClient(otherOwner, map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "client_name": factory.SafeName("Foreign Client"), + "visibility": "private", + "redirect_uris": []string{"http://localhost:9999/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "scopes": "openid", + }) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 3. Authorization Code Flow (with PKCE) +// --------------------------------------------------------------------------- + +func TestOAuth2_AuthorizationCodeFlow(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "full happy path with PKCE", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokenResp := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + assert.NotEmpty(t, tokenResp.AccessToken) + assert.NotEmpty(t, tokenResp.RefreshToken) + assert.NotEmpty(t, tokenResp.IDToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, int64(0)) + assert.Contains(t, tokenResp.Scope, "openid") + }, + ) + + t.Run( + "consent deny returns access_denied", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + _ = verifier + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"deny-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect") + + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + denyResp, err := testutil.OAuth2ConsentDeny(owner, consentID) + require.NoError(t, err) + require.Equal(t, http.StatusFound, denyResp.StatusCode) + + loc := denyResp.Header.Get("Location") + assert.Contains(t, loc, "error=access_denied") + }, + ) + + t.Run( + "invalid scope", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid super_admin"}, + "state": {"scope-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode) + } + }, + ) + + t.Run( + "bad code verifier fails token exchange", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"pkce-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + _, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + "wrong-verifier-that-does-not-match-the-challenge-at-all", + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode) + }, + ) + + t.Run( + "code reuse fails", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"reuse-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, tokenResp) + + _, raw2, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw2.StatusCode, "second exchange should fail") + }, + ) +} + +// --------------------------------------------------------------------------- +// 4. Refresh Token Flow +// --------------------------------------------------------------------------- + +func TestOAuth2_RefreshToken(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "token rotation", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + firstTokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + refreshResp, raw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.RefreshToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, "refresh failed: %s", string(raw.Body)) + require.NotNil(t, refreshResp) + + assert.NotEqual(t, firstTokens.AccessToken, refreshResp.AccessToken) + assert.NotEqual(t, firstTokens.RefreshToken, refreshResp.RefreshToken) + assert.NotEmpty(t, refreshResp.IDToken, "should include id_token for openid scope") + assert.Equal(t, "Bearer", refreshResp.TokenType) + assert.Greater(t, refreshResp.ExpiresIn, int64(0)) + }, + ) + + t.Run( + "replay detection revokes all tokens", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + firstTokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + secondTokens, raw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.RefreshToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, secondTokens) + + _, replayRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, replayRaw.StatusCode, "replayed refresh token should fail") + + _, newRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + secondTokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual( + t, + http.StatusOK, + newRaw.StatusCode, + "new refresh token should also be revoked after replay detection", + ) + }, + ) + + t.Run( + "cross-client refresh token theft rejected", + func(t *testing.T) { + t.Parallel() + + clientA := factory.CreateOAuth2Client(owner, nil) + clientB := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + clientA.ClientID, + clientA.ClientSecret, + redirectURI, + ) + + _, raw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + clientB.ClientID, + clientB.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, + "client B must not be able to use client A's refresh token") + }, + ) + + t.Run( + "invalid refresh token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + _, raw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + "totally-invalid-refresh-token", + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 5. Device Code Flow +// --------------------------------------------------------------------------- + +func TestOAuth2_DeviceCodeFlow(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "full happy path", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid email profile", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, "device auth failed: %s", string(raw.Body)) + require.NotNil(t, deviceResp) + + assert.NotEmpty(t, deviceResp.DeviceCode) + assert.NotEmpty(t, deviceResp.UserCode) + assert.NotEmpty(t, deviceResp.VerificationURI) + assert.Greater(t, deviceResp.ExpiresIn, 0) + assert.Greater(t, deviceResp.Interval, 0) + + _, errResp, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp) + assert.Equal(t, "authorization_pending", errResp.Code) + + userCode := deviceResp.UserCode + verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode) + require.NoError(t, err) + require.Equal(t, http.StatusOK, verifyResp.StatusCode, "device verify failed: %s", string(verifyResp.Body)) + + time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second) + + tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, pollRaw.StatusCode, "device token poll failed: %s", string(pollRaw.Body)) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, int64(0)) + }, + ) + + t.Run( + "invalid client", + func(t *testing.T) { + t.Parallel() + + _, raw, err := testutil.OAuth2DeviceAuth( + owner, + "nonexistent-client-id", + "openid", + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode) + }, + ) + + t.Run( + "slow down polling", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, _, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid", + ) + require.NoError(t, err) + require.NotNil(t, deviceResp) + + _, errResp1, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp1) + assert.Equal(t, "authorization_pending", errResp1.Code) + + _, errResp2, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp2) + assert.Equal(t, "slow_down", errResp2.Code) + }, + ) +} + +// --------------------------------------------------------------------------- +// 6. Token Introspection +// --------------------------------------------------------------------------- + +func TestOAuth2_Introspect(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "active token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + introspect, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + + assert.True(t, introspect.Active) + assert.Equal(t, "Bearer", introspect.TokenType) + assert.NotEmpty(t, introspect.Sub) + assert.Greater(t, introspect.Exp, int64(0)) + assert.NotEmpty(t, introspect.Scope, "introspection should return scope") + assert.Equal(t, client.ClientID, introspect.ClientID, "introspection should return client_id") + }, + ) + + t.Run( + "revoked token is inactive", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + revokeRaw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, revokeRaw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "wrong client gets inactive", + func(t *testing.T) { + t.Parallel() + + clientA := factory.CreateOAuth2Client(owner, nil) + clientB := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + clientA.ClientID, + clientA.ClientSecret, + redirectURI, + ) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + clientB.ClientID, + clientB.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "bad client auth", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + _, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + "wrong-secret", + "some-token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 7. Token Revocation +// --------------------------------------------------------------------------- + +func TestOAuth2_Revoke(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "revoke access token then introspect is inactive", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "revoke refresh token then refresh fails", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + _, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode) + }, + ) + + t.Run( + "unknown token returns 200 per RFC 7009", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + raw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + "unknown-token-that-does-not-exist", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + }, + ) + + t.Run( + "bad client auth", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + raw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + "wrong-secret", + "some-token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) + + t.Run( + "token_type_hint=access_token revokes access token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + "access_token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "token_type_hint=refresh_token revokes refresh token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + "refresh_token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + _, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode) + }, + ) + + t.Run( + "wrong token_type_hint still finds and revokes the token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + // Send access token with refresh_token hint — server must + // extend search across all types per RFC 7009 §2.1. + raw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + "refresh_token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "invalid token_type_hint is ignored per RFC 7009", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + "bogus_hint", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active) + }, + ) + + t.Run( + "revoking refresh token cascades to linked access token per RFC 7009", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + // Revoke the refresh token. + raw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + "refresh_token", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + + // The linked access token should also be invalidated. + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active, "access token should be revoked when refresh token is revoked") + }, + ) + + t.Run( + "empty token returns 200", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + raw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + "", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 8. UserInfo +// --------------------------------------------------------------------------- + +func TestOAuth2_UserInfo(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "returns claims for openid email profile scopes", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + userInfo, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, "userinfo failed: %s", string(raw.Body)) + require.NotNil(t, userInfo) + + assert.NotEmpty(t, userInfo.Sub) + assert.NotEmpty(t, userInfo.Email) + assert.NotEmpty(t, userInfo.Name) + }, + ) + + t.Run( + "revoked access token returns 401", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + revokeRaw, err := testutil.OAuth2Revoke( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, revokeRaw.StatusCode) + + _, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode, + "revoked access token must be rejected by userinfo") + }, + ) + + t.Run( + "no bearer token returns 401", + func(t *testing.T) { + t.Parallel() + + _, raw, err := testutil.OAuth2UserInfo(owner, "") + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) + + t.Run( + "invalid bearer token returns 401", + func(t *testing.T) { + t.Parallel() + + _, raw, err := testutil.OAuth2UserInfo(owner, "invalid-access-token") + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 9. Token Endpoint Errors +// --------------------------------------------------------------------------- + +func TestOAuth2_Token_Errors(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "unsupported grant type", + func(t *testing.T) { + t.Parallel() + + raw, err := testutil.OAuth2TokenRaw(owner, url.Values{ + "grant_type": {"password"}, + "username": {"user"}, + "password": {"pass"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, raw.StatusCode) + + var errResp testutil.OAuth2ErrorResponse + require.NoError(t, json.Unmarshal(raw.Body, &errResp)) + assert.Equal(t, "unsupported_grant_type", errResp.Code) + }, + ) + + t.Run( + "missing grant type", + func(t *testing.T) { + t.Parallel() + + raw, err := testutil.OAuth2TokenRaw(owner, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, raw.StatusCode) + }, + ) + + t.Run( + "bad client auth on authorization code grant", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + raw, err := testutil.OAuth2TokenRawWithBasicAuth( + owner, + url.Values{ + "grant_type": {"authorization_code"}, + "code": {"fake-code"}, + }, + client.ClientID, + "wrong-secret", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) + + t.Run( + "slow down device code polling", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, _, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid", + ) + require.NoError(t, err) + require.NotNil(t, deviceResp) + + _, errResp1, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp1) + + _, errResp2, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp2) + assert.Equal(t, "slow_down", errResp2.Code) + + time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second) + + _, errResp3, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp3) + assert.Equal(t, "authorization_pending", errResp3.Code, "after waiting, should not get slow_down") + }, + ) +} + +// --------------------------------------------------------------------------- +// 10. Security +// --------------------------------------------------------------------------- + +func TestOAuth2_Security(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "public client requires PKCE", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"no-pkce"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=", "public client without code_challenge should be rejected") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode) + } + }, + ) + + t.Run( + "only S256 code challenge method accepted", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"plain-method"}, + "code_challenge": {"some-challenge-value"}, + "code_challenge_method": {"plain"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=", "plain code_challenge_method should be rejected") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode) + } + }, + ) + + t.Run( + "redirect URI mismatch at token exchange", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"redirect-mismatch"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + _, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + "http://localhost:9999/WRONG-callback", + verifier, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, "mismatched redirect_uri must fail") + }, + ) + + t.Run( + "state parameter roundtrip", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + state := "csrf-protection-nonce-abc123" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {state}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var redirectLoc string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + require.Equal(t, http.StatusFound, consentResp.StatusCode) + + redirectLoc = consentResp.Header.Get("Location") + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + redirectLoc = authResp.Header.Get("Location") + } + + u, err := url.Parse(redirectLoc) + require.NoError(t, err) + assert.Equal(t, state, u.Query().Get("state"), "state must be returned unchanged") + assert.NotEmpty(t, u.Query().Get("code"), "code must be present") + }, + ) + + t.Run( + "scope escalation rejected", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, factory.Attrs{ + "scopes": "openid", + }) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"scope-esc"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=", "requesting scopes beyond registration must fail") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode, + "should not show consent page for disallowed scopes") + } + }, + ) + + t.Run( + "cross-client code exchange rejected", + func(t *testing.T) { + t.Parallel() + + clientA := factory.CreateOAuth2Client(owner, nil) + clientB := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {clientA.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"cross-client"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + _, raw, err := testutil.OAuth2TokenWithCode( + owner, + clientB.ClientID, + clientB.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, + "code issued to client A must not be exchangeable by client B") + }, + ) + + t.Run( + "unregistered redirect URI rejected at authorize", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {"http://localhost:9999/evil"}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"open-redirect"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, authResp.StatusCode, + "unregistered redirect_uri must not show consent page") + assert.NotEqual(t, http.StatusFound, authResp.StatusCode, + "invalid redirect_uri must not redirect (must return JSON error)") + + var errResp testutil.OAuth2ErrorResponse + if json.Unmarshal(authResp.Body, &errResp) == nil { + assert.Equal(t, "invalid_redirect_uri", errResp.Code, + "should return invalid_redirect_uri error code") + } + }, + ) + + t.Run( + "private client rejects non-member at authorize", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + _, challenge := testutil.GeneratePKCE() + + otherOwner := testutil.NewClient(t, testutil.RoleOwner) + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {"http://localhost:9999/callback"}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"non-member"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(otherOwner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=", + "non-member must be rejected when authorizing against a private client") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode, + "non-member must not see the consent page for a private client") + } + }, + ) + + t.Run( + "consent skipped on re-authorization with same scopes", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge1 := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"consent-first"}, + "code_challenge": {challenge1}, + "code_challenge_method": {"S256"}, + } + + firstResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + require.True(t, testutil.IsConsentRedirect(firstResp), "first request should require consent") + + consentID, err := testutil.ExtractConsentIDFromResponse(firstResp) + require.NoError(t, err) + + _, err = testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + _, challenge2 := testutil.GeneratePKCE() + params.Set("state", "consent-second") + params.Set("code_challenge", challenge2) + + secondResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + assert.Equal(t, http.StatusFound, secondResp.StatusCode, + "second authorize with same scopes should skip consent and redirect with code") + + code, err := testutil.OAuth2AuthorizeCodeFromRedirect(secondResp) + require.NoError(t, err) + assert.NotEmpty(t, code) + }, + ) + + t.Run( + "ID token contains nonce from authorize request", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + nonce := "test-nonce-value-abc123" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"nonce-test"}, + "nonce": {nonce}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotEmpty(t, tokenResp.IDToken) + + parts := strings.SplitN(tokenResp.IDToken, ".", 3) + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims struct { + Nonce string `json:"nonce"` + } + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.Equal(t, nonce, claims.Nonce, + "ID token must contain the nonce from the authorize request") + }, + ) + + t.Run( + "ID token contains valid at_hash claim", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + require.NotEmpty(t, tokens.IDToken) + + parts := strings.SplitN(tokens.IDToken, ".", 3) + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims struct { + AtHash string `json:"at_hash"` + } + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + require.NotEmpty(t, claims.AtHash, "at_hash must be present in ID token") + + h := sha256.Sum256([]byte(tokens.AccessToken)) + expectedAtHash := base64.RawURLEncoding.EncodeToString(h[:16]) + assert.Equal(t, expectedAtHash, claims.AtHash, + "at_hash must be the left half of SHA-256 of the access token, base64url-encoded") + }, + ) + + t.Run( + "ID token signature verifiable with JWKS", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + require.NotEmpty(t, tokens.IDToken, "expected id_token") + + // Parse JWT parts. + parts := strings.SplitN(tokens.IDToken, ".", 3) + require.Len(t, parts, 3, "JWT must have 3 parts") + + // Decode and verify header. + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + Kid string `json:"kid"` + } + require.NoError(t, json.Unmarshal(headerJSON, &header)) + assert.Equal(t, "RS256", header.Alg) + assert.Equal(t, "JWT", header.Typ) + assert.NotEmpty(t, header.Kid) + + // Decode claims and verify standard fields. + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + AuthTime int64 `json:"auth_time"` + } + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.NotEmpty(t, claims.Iss) + assert.NotEmpty(t, claims.Sub) + assert.Equal(t, client.ClientID, claims.Aud) + assert.Greater(t, claims.Exp, time.Now().Unix(), "token must not be expired") + assert.LessOrEqual(t, claims.Iat, time.Now().Unix()) + assert.Greater(t, claims.AuthTime, int64(0), "auth_time must be set") + + // Fetch JWKS and find the matching key. + jwks, _, err := testutil.OAuth2JWKS(owner) + require.NoError(t, err) + require.NotNil(t, jwks) + + var matchingKey map[string]any + for _, k := range jwks.Keys { + if kid, ok := k["kid"].(string); ok && kid == header.Kid { + matchingKey = k + break + } + } + require.NotNil(t, matchingKey, "JWKS must contain key matching kid=%s", header.Kid) + + // Reconstruct the RSA public key from JWK. + nB64, ok := matchingKey["n"].(string) + require.True(t, ok) + eB64, ok := matchingKey["e"].(string) + require.True(t, ok) + + nBytes, err := base64.RawURLEncoding.DecodeString(nB64) + require.NoError(t, err) + eBytes, err := base64.RawURLEncoding.DecodeString(eB64) + require.NoError(t, err) + + pubKey := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + + // Verify RS256 signature. + signingInput := parts[0] + "." + parts[1] + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + + digest := sha256.Sum256([]byte(signingInput)) + err = rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes) + assert.NoError(t, err, "ID token signature must be verifiable with JWKS public key") + }, + ) + + t.Run( + "consent approval with different user rejected", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"csrf-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect") + + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + otherOwner := testutil.NewClient(t, testutil.RoleOwner) + + approveResp, err := testutil.OAuth2ConsentApprove(otherOwner, consentID) + require.NoError(t, err) + assert.NotEqual(t, http.StatusFound, approveResp.StatusCode, + "consent approval by a different user must be rejected") + }, + ) + + t.Run( + "cross-client revocation does not revoke the token", + func(t *testing.T) { + t.Parallel() + + clientA := factory.CreateOAuth2Client(owner, nil) + clientB := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + clientA.ClientID, + clientA.ClientSecret, + redirectURI, + ) + + revokeRaw, err := testutil.OAuth2Revoke( + owner, + clientB.ClientID, + clientB.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, revokeRaw.StatusCode, + "cross-client revoke should return 200 per RFC 7009") + + introspect, _, err := testutil.OAuth2Introspect( + owner, + clientA.ClientID, + clientA.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + assert.True(t, introspect.Active, + "token must still be active when revoked by a different client") + }, + ) + + t.Run( + "bearer token in query string rejected by userinfo", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + raw, err := testutil.OAuth2UserInfoRaw(owner, url.Values{ + "access_token": {tokens.AccessToken}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode, + "bearer token in query string must not authenticate") + }, + ) + + t.Run( + "confidential client can complete flow without PKCE", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"no-pkce-confidential"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + "", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, + "confidential client should succeed without PKCE: %s", string(raw.Body)) + assert.NotEmpty(t, tokenResp.AccessToken) + }, + ) +} + +// --------------------------------------------------------------------------- +// 11. Client Secret Post Authentication +// --------------------------------------------------------------------------- + +func TestOAuth2_ClientSecretPost(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "full auth code flow with client_secret_post", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, factory.Attrs{ + "token_endpoint_auth_method": "client_secret_post", + }) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"post-auth"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCodePostAuth( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, + "client_secret_post exchange failed: %s", string(raw.Body)) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, int64(0)) + }, + ) + + t.Run( + "wrong secret via client_secret_post returns 401", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, factory.Attrs{ + "token_endpoint_auth_method": "client_secret_post", + }) + + raw, err := testutil.OAuth2TokenRaw(owner, url.Values{ + "grant_type": {"authorization_code"}, + "code": {"fake-code"}, + "client_id": {client.ClientID}, + "client_secret": {"wrong-secret"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 12. Public Client Authorization Code Flow +// --------------------------------------------------------------------------- + +func TestOAuth2_PublicClientAuthCodeFlow(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "full happy path with PKCE and no secret", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"public-pkce"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCodePostAuth( + owner, + client.ClientID, + "", + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, + "public client token exchange failed: %s", string(raw.Body)) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, int64(0)) + assert.Contains(t, tokenResp.Scope, "openid") + }, + ) +} + +// --------------------------------------------------------------------------- +// 13. Registration Edge Cases +// --------------------------------------------------------------------------- + +func TestOAuth2_RegisterClient_EdgeCases(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "public client with http redirect URI rejected", + func(t *testing.T) { + t.Parallel() + + _, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "client_name": factory.SafeName("Public HTTP"), + "visibility": "public", + "redirect_uris": []string{"http://example.com/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + "scopes": "openid", + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, raw.StatusCode, + "public clients must require https redirect URIs") + }, + ) + + t.Run( + "public client with https redirect URI accepted", + func(t *testing.T) { + t.Parallel() + + resp, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{ + "organization_id": owner.GetOrganizationID().String(), + "client_name": factory.SafeName("Public HTTPS"), + "visibility": "public", + "redirect_uris": []string{"https://example.com/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + "scopes": "openid", + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, raw.StatusCode, "body: %s", string(raw.Body)) + assert.NotEmpty(t, resp.ClientID) + }, + ) +} + +// --------------------------------------------------------------------------- +// 14. ID Token Claims +// --------------------------------------------------------------------------- + +func TestOAuth2_IDTokenClaims(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "ID token from auth code flow contains email and name claims", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + require.NotEmpty(t, tokens.IDToken) + + parts := strings.SplitN(tokens.IDToken, ".", 3) + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + AuthTime int64 `json:"auth_time"` + Email string `json:"email"` + EmailVerified *bool `json:"email_verified"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + + assert.NotEmpty(t, claims.Iss) + assert.NotEmpty(t, claims.Sub) + assert.NotEmpty(t, claims.Aud) + assert.NotEmpty(t, claims.Exp) + assert.NotEmpty(t, claims.Iat) + assert.NotEmpty(t, claims.AuthTime) + + assert.NotEmpty(t, claims.Email, + "ID token must contain email when email scope is requested") + require.NotNil(t, claims.EmailVerified, + "ID token must contain email_verified when email scope is requested") + assert.False(t, *claims.EmailVerified, + "email_verified must be false for unverified e2e test identity") + assert.NotEmpty(t, claims.Name, + "ID token must contain name when profile scope is requested") + }, + ) + + t.Run( + "ID token from refresh contains identity claims and omits nonce", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + nonce := "test-refresh-nonce" + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile offline_access"}, + "state": {"refresh-nonce"}, + "nonce": {nonce}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + firstTokens, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + + refreshResp, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.RefreshToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, refreshRaw.StatusCode) + require.NotEmpty(t, refreshResp.IDToken) + + parts := strings.SplitN(refreshResp.IDToken, ".", 3) + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims struct { + Nonce string `json:"nonce"` + AuthTime int64 `json:"auth_time"` + Email string `json:"email"` + EmailVerified *bool `json:"email_verified"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + + assert.Empty(t, claims.Nonce, + "ID token from refresh must not contain the original nonce") + assert.NotEmpty(t, claims.AuthTime, + "refresh ID token must contain auth_time") + assert.NotEmpty(t, claims.Email, + "refresh ID token must contain email when email scope is present") + require.NotNil(t, claims.EmailVerified, + "refresh ID token must contain email_verified when email scope is present") + assert.False(t, *claims.EmailVerified, + "email_verified must be false for unverified e2e test identity") + assert.NotEmpty(t, claims.Name, + "refresh ID token must contain name when profile scope is present") + }, + ) +} + +// --------------------------------------------------------------------------- +// 15. Cache-Control Headers +// --------------------------------------------------------------------------- + +func TestOAuth2_CacheHeaders(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "token endpoint response has no-store cache header", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"cache-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + _, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + + cacheControl := raw.Header.Get("Cache-Control") + assert.Contains(t, cacheControl, "no-store", + "token response must include Cache-Control: no-store per RFC 6749 section 5.1") + }, + ) +} + +// --------------------------------------------------------------------------- +// 16. Device Flow Edge Cases +// --------------------------------------------------------------------------- + +func TestOAuth2_DeviceCodeFlow_EdgeCases(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "invalid user code on device verify", + func(t *testing.T) { + t.Parallel() + + verifyResp, err := testutil.OAuth2DeviceVerify(owner, "ZZZZ-ZZZZ") + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, verifyResp.StatusCode) + assert.Contains(t, strings.ToLower(string(verifyResp.Body)), "error", + "response should indicate verification failure") + }, + ) + + t.Run( + "scope exceeding client registration rejected", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, factory.Attrs{ + "scopes": "openid", + }) + + _, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid email profile", + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, + "device auth with scope exceeding registration must be rejected") + }, + ) +} + +// --------------------------------------------------------------------------- +// 17. Authorize Endpoint Edge Cases +// --------------------------------------------------------------------------- + +func TestOAuth2_Authorize_EdgeCases(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "unsupported response_type rejected", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"token"}, + "scope": {"openid"}, + "state": {"implicit-attempt"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=", + "response_type=token must be rejected") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode, + "response_type=token must not show consent page") + } + }, + ) + + t.Run( + "already approved consent cannot be resubmitted", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"double-approve"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect") + + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + firstApproval, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + require.Equal(t, http.StatusFound, firstApproval.StatusCode, + "first consent approval should redirect with code") + + secondApproval, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + assert.NotEqual(t, http.StatusFound, secondApproval.StatusCode, + "second consent approval must be rejected") + }, + ) +} + +// --------------------------------------------------------------------------- +// 18. Introspect Edge Cases +// --------------------------------------------------------------------------- + +func TestOAuth2_Introspect_EdgeCases(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "unknown token returns inactive with valid client auth", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + introspect, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + "completely-unknown-token-that-was-never-issued", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode, + "introspect of unknown token must return 200 per RFC 7662") + assert.False(t, introspect.Active, + "unknown token must be reported as inactive") + }, + ) + + t.Run( + "empty token returns error", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + + _, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + "", + ) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, raw.StatusCode, + "empty token should be rejected by input validation") + }, + ) +} + +// --------------------------------------------------------------------------- +// 19. Token Expiry (requires short durations in e2e config) +// --------------------------------------------------------------------------- + +func TestOAuth2_Expiry(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "expired authorization code rejected at token exchange", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid"}, + "state": {"expiry-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + // e2e config sets authorization-code-duration to 5s + time.Sleep(6 * time.Second) + + _, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, + "expired authorization code must be rejected") + }, + ) + + t.Run( + "expired access token rejected by userinfo", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + // e2e config sets access-token-duration to 10s + time.Sleep(11 * time.Second) + + _, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, raw.StatusCode, + "expired access token must be rejected by userinfo") + }, + ) + + t.Run( + "expired access token introspects as inactive", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + // e2e config sets access-token-duration to 10s + time.Sleep(11 * time.Second) + + introspect, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + assert.False(t, introspect.Active, + "expired access token must introspect as inactive") + }, + ) + + t.Run( + "expired refresh token rejected", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + // e2e config sets refresh-token-duration to 10s + time.Sleep(11 * time.Second) + + _, raw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, raw.StatusCode, + "expired refresh token must be rejected") + }, + ) + + t.Run( + "expired device code rejected at poll", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, deviceResp) + + // e2e config sets device-code-duration to 15s + time.Sleep(16 * time.Second) + + _, errResp, _, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.NotNil(t, errResp) + assert.Equal(t, "expired_token", errResp.Code, + "expired device code must return expired_token error") + }, + ) +} + +// --------------------------------------------------------------------------- +// 20. Offline Access Scope +// --------------------------------------------------------------------------- + +func TestOAuth2_OfflineAccessScope(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "refresh token issued with offline_access scope", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid offline_access"}, + "state": {"offline-yes"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.RefreshToken, + "refresh token must be issued when offline_access scope is requested") + }, + ) + + t.Run( + "no refresh token without offline_access scope", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"offline-no"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + tokenResp, raw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Empty(t, tokenResp.RefreshToken, + "refresh token must not be issued without offline_access scope") + }, + ) + + t.Run( + "offline_access rejected without refresh_token grant type", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, factory.Attrs{ + "grant_types": []string{"authorization_code"}, + "scopes": "openid offline_access", + }) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid offline_access"}, + "state": {"no-grant-type"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + if authResp.StatusCode == http.StatusFound { + loc := authResp.Header.Get("Location") + assert.Contains(t, loc, "error=invalid_scope", + "offline_access without refresh_token grant type must return invalid_scope") + } else { + assert.NotEqual(t, http.StatusOK, authResp.StatusCode, + "offline_access without refresh_token grant type must not show consent page") + } + }, + ) + + t.Run( + "device flow with offline_access scope issues refresh token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid offline_access", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, deviceResp) + + userCode := deviceResp.UserCode + verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode) + require.NoError(t, err) + require.Equal(t, http.StatusOK, verifyResp.StatusCode) + + time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second) + + tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, pollRaw.StatusCode) + require.NotNil(t, tokenResp) + + assert.NotEmpty(t, tokenResp.RefreshToken, + "device flow must issue refresh token when offline_access is requested") + }, + ) + + t.Run( + "device flow without offline_access scope has no refresh token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + + deviceResp, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid email profile", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + require.NotNil(t, deviceResp) + + userCode := deviceResp.UserCode + verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode) + require.NoError(t, err) + require.Equal(t, http.StatusOK, verifyResp.StatusCode) + + time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second) + + tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode( + owner, + client.ClientID, + deviceResp.DeviceCode, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, pollRaw.StatusCode) + require.NotNil(t, tokenResp) + + assert.Empty(t, tokenResp.RefreshToken, + "device flow must not issue refresh token without offline_access scope") + }, + ) + + t.Run( + "device flow offline_access rejected without refresh_token grant type", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, factory.Attrs{ + "grant_types": []string{ + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", + }, + "scopes": "openid offline_access", + }) + + _, raw, err := testutil.OAuth2DeviceAuth( + owner, + client.ClientID, + "openid offline_access", + ) + require.NoError(t, err) + require.NotEqual(t, http.StatusOK, raw.StatusCode, + "device flow with offline_access but no refresh_token grant type must be rejected") + + var errResp testutil.OAuth2ErrorResponse + require.NoError(t, json.Unmarshal(raw.Body, &errResp)) + assert.Equal(t, "invalid_scope", errResp.Code) + assert.Contains(t, errResp.Description, "refresh_token") + }, + ) + + t.Run( + "authorize offline_access error includes description", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, factory.Attrs{ + "grant_types": []string{"authorization_code"}, + "scopes": "openid offline_access", + }) + redirectURI := "http://localhost:9999/callback" + _, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid offline_access"}, + "state": {"err-desc"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + require.Equal(t, http.StatusFound, authResp.StatusCode) + + loc, err := url.Parse(authResp.Header.Get("Location")) + require.NoError(t, err) + + assert.Equal(t, "invalid_scope", loc.Query().Get("error")) + assert.Contains(t, loc.Query().Get("error_description"), "refresh_token", + "error description should mention missing refresh_token grant type") + assert.Equal(t, "err-desc", loc.Query().Get("state"), + "state parameter must be preserved in error redirect") + }, + ) +} + +// --------------------------------------------------------------------------- +// 20. RFC 6819 Compliance: Public Client Consent Skip Prevention +// --------------------------------------------------------------------------- + +func TestOAuth2_PublicClientAlwaysRequiresConsent(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "public client must prompt consent on every authorization", + func(t *testing.T) { + t.Parallel() + + client := factory.CreatePublicOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + // First flow: authorize and approve consent explicitly. + verifier1, challenge1 := testutil.GeneratePKCE() + params1 := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"first"}, + "code_challenge": {challenge1}, + "code_challenge_method": {"S256"}, + } + + authResp1, err := testutil.OAuth2Authorize(owner, params1) + require.NoError(t, err) + + // First request must require consent (redirect to consent page). + require.True(t, testutil.IsConsentRedirect(authResp1), "first authorization must require consent for public client") + consentID1, err := testutil.ExtractConsentIDFromResponse(authResp1) + require.NoError(t, err) + + consentResp1, err := testutil.OAuth2ConsentApprove(owner, consentID1) + require.NoError(t, err) + require.Equal(t, http.StatusFound, consentResp1.StatusCode) + + code1, err := testutil.OAuth2AuthorizeCodeFromRedirect(consentResp1) + require.NoError(t, err) + + tokenResp1, raw1, err := testutil.OAuth2TokenWithCodePostAuth( + owner, + client.ClientID, + "", + code1, + redirectURI, + verifier1, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw1.StatusCode, "first token exchange failed: %s", string(raw1.Body)) + require.NotEmpty(t, tokenResp1.AccessToken) + + // Second flow with same client+scopes: must STILL require consent + // (RFC 6819 §5.2.3.2 — no auto-consent for public clients). + _, challenge2 := testutil.GeneratePKCE() + params2 := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"second"}, + "code_challenge": {challenge2}, + "code_challenge_method": {"S256"}, + } + + authResp2, err := testutil.OAuth2Authorize(owner, params2) + require.NoError(t, err) + + // The key assertion: the second authorization must also require + // explicit consent, not silently issue a code via redirect. + require.True(t, testutil.IsConsentRedirect(authResp2), + "public client must always require consent, got code redirect (auto-consent)") + + _, err = testutil.ExtractConsentIDFromResponse(authResp2) + require.NoError(t, err, + "second authorization must present consent form for public client") + }, + ) + + t.Run( + "confidential client can skip consent on repeat authorization", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + // First flow: approve consent. + verifier1, challenge1 := testutil.GeneratePKCE() + params1 := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"first"}, + "code_challenge": {challenge1}, + "code_challenge_method": {"S256"}, + } + + authResp1, err := testutil.OAuth2Authorize(owner, params1) + require.NoError(t, err) + + require.True(t, testutil.IsConsentRedirect(authResp1), "first authorization should require consent") + consentID, err := testutil.ExtractConsentIDFromResponse(authResp1) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + require.Equal(t, http.StatusFound, consentResp.StatusCode) + + code1, err := testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + + _, raw1, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code1, + redirectURI, + verifier1, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw1.StatusCode) + + // Second flow with same scopes: confidential client may skip consent. + verifier2, challenge2 := testutil.GeneratePKCE() + params2 := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {"second"}, + "code_challenge": {challenge2}, + "code_challenge_method": {"S256"}, + } + + authResp2, err := testutil.OAuth2Authorize(owner, params2) + require.NoError(t, err) + require.Equal(t, http.StatusFound, authResp2.StatusCode, + "confidential client should auto-consent on repeat authorization") + + code2, err := testutil.OAuth2AuthorizeCodeFromRedirect(authResp2) + require.NoError(t, err) + require.NotEmpty(t, code2) + + _, raw2, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code2, + redirectURI, + verifier2, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw2.StatusCode) + }, + ) +} + +// --------------------------------------------------------------------------- +// 21. RFC 6819 Compliance: Authorization Code Replay Detection +// --------------------------------------------------------------------------- + +func TestOAuth2_AuthorizationCodeReplayRevokesTokens(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "second code exchange revokes tokens from first exchange", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + verifier, challenge := testutil.GeneratePKCE() + + params := url.Values{ + "client_id": {client.ClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile offline_access"}, + "state": {"replay-test"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + authResp, err := testutil.OAuth2Authorize(owner, params) + require.NoError(t, err) + + var code string + if testutil.IsConsentRedirect(authResp) { + consentID, err := testutil.ExtractConsentIDFromResponse(authResp) + require.NoError(t, err) + + consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID) + require.NoError(t, err) + require.Equal(t, http.StatusFound, consentResp.StatusCode) + + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp) + require.NoError(t, err) + } else { + require.Equal(t, http.StatusFound, authResp.StatusCode) + code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp) + require.NoError(t, err) + } + + // First exchange: should succeed. + firstTokens, firstRaw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, firstRaw.StatusCode, "first exchange failed: %s", string(firstRaw.Body)) + require.NotNil(t, firstTokens) + require.NotEmpty(t, firstTokens.AccessToken) + require.NotEmpty(t, firstTokens.RefreshToken) + + // Verify the access token works before replay. + introspect1, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.AccessToken, + ) + require.NoError(t, err) + assert.True(t, introspect1.Active, "access token must be active before replay attempt") + + // Second exchange with same code: should fail (replay). + _, replayRaw, err := testutil.OAuth2TokenWithCode( + owner, + client.ClientID, + client.ClientSecret, + code, + redirectURI, + verifier, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, replayRaw.StatusCode, + "replayed authorization code must be rejected") + + // The access token from the first exchange must now be revoked. + introspect2, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.AccessToken, + ) + require.NoError(t, err) + assert.False(t, introspect2.Active, + "access token must be revoked after authorization code replay") + + // The refresh token from the first exchange must also be revoked. + _, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken( + owner, + client.ClientID, + client.ClientSecret, + firstTokens.RefreshToken, + ) + require.NoError(t, err) + assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode, + "refresh token must be revoked after authorization code replay") + }, + ) +} diff --git a/e2e/console/testdata/config.yaml b/e2e/console/testdata/config.yaml index 5367f08aa..50754d439 100644 --- a/e2e/console/testdata/config.yaml +++ b/e2e/console/testdata/config.yaml @@ -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" diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index 0be64d0de..47649605f 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -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, + } +} diff --git a/e2e/internal/testutil/client.go b/e2e/internal/testutil/client.go index 06dd89867..f652f5607 100644 --- a/e2e/internal/testutil/client.go +++ b/e2e/internal/testutil/client.go @@ -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 } diff --git a/e2e/internal/testutil/oauth2.go b/e2e/internal/testutil/oauth2.go new file mode 100644 index 000000000..9ed54ca5b --- /dev/null +++ b/e2e/internal/testutil/oauth2.go @@ -0,0 +1,909 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 +} diff --git a/e2e/internal/testutil/testutil.go b/e2e/internal/testutil/testutil.go index c574ee68f..2e4cb7336 100644 --- a/e2e/internal/testutil/testutil.go +++ b/e2e/internal/testutil/testutil.go @@ -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 +} diff --git a/go.mod b/go.mod index 314ee1583..d0bc7b180 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 153421b3a..bc0a1737c 100644 --- a/go.sum +++ b/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= diff --git a/pkg/cli/api/client.go b/pkg/cli/api/client.go index 1610e3135..e0d03f711 100644 --- a/pkg/cli/api/client.go +++ b/pkg/cli/api/client.go @@ -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 } diff --git a/pkg/cli/config/config.go b/pkg/cli/config/config.go index 60940e252..e36bb160d 100644 --- a/pkg/cli/config/config.go +++ b/pkg/cli/config/config.go @@ -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"` } ) diff --git a/pkg/cmd/access-review/campaign/addsource/addsource.go b/pkg/cmd/access-review/campaign/addsource/addsource.go index 5c7ea34c9..3bc6aca90 100644 --- a/pkg/cmd/access-review/campaign/addsource/addsource.go +++ b/pkg/cmd/access-review/campaign/addsource/addsource.go @@ -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{ diff --git a/pkg/cmd/access-review/campaign/cancel/cancel.go b/pkg/cmd/access-review/campaign/cancel/cancel.go index 4811943be..b4ac9a534 100644 --- a/pkg/cmd/access-review/campaign/cancel/cancel.go +++ b/pkg/cmd/access-review/campaign/cancel/cancel.go @@ -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( diff --git a/pkg/cmd/access-review/campaign/close/close.go b/pkg/cmd/access-review/campaign/close/close.go index 3db26939b..e25690797 100644 --- a/pkg/cmd/access-review/campaign/close/close.go +++ b/pkg/cmd/access-review/campaign/close/close.go @@ -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( diff --git a/pkg/cmd/access-review/campaign/create/create.go b/pkg/cmd/access-review/campaign/create/create.go index f20458891..b1eba1c97 100644 --- a/pkg/cmd/access-review/campaign/create/create.go +++ b/pkg/cmd/access-review/campaign/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/access-review/campaign/delete/delete.go b/pkg/cmd/access-review/campaign/delete/delete.go index 04f47d50b..133f77a6a 100644 --- a/pkg/cmd/access-review/campaign/delete/delete.go +++ b/pkg/cmd/access-review/campaign/delete/delete.go @@ -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( diff --git a/pkg/cmd/access-review/campaign/list/list.go b/pkg/cmd/access-review/campaign/list/list.go index 309a9e660..fe854c670 100644 --- a/pkg/cmd/access-review/campaign/list/list.go +++ b/pkg/cmd/access-review/campaign/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/access-review/campaign/removesource/removesource.go b/pkg/cmd/access-review/campaign/removesource/removesource.go index 2da120572..da7b98aa8 100644 --- a/pkg/cmd/access-review/campaign/removesource/removesource.go +++ b/pkg/cmd/access-review/campaign/removesource/removesource.go @@ -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{ diff --git a/pkg/cmd/access-review/campaign/start/start.go b/pkg/cmd/access-review/campaign/start/start.go index 82d5c3c72..af318aefd 100644 --- a/pkg/cmd/access-review/campaign/start/start.go +++ b/pkg/cmd/access-review/campaign/start/start.go @@ -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{ diff --git a/pkg/cmd/access-review/campaign/update/update.go b/pkg/cmd/access-review/campaign/update/update.go index 528a5539e..986d9b2eb 100644 --- a/pkg/cmd/access-review/campaign/update/update.go +++ b/pkg/cmd/access-review/campaign/update/update.go @@ -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{ diff --git a/pkg/cmd/access-review/campaign/view/view.go b/pkg/cmd/access-review/campaign/view/view.go index f9fa6e152..17dc2f920 100644 --- a/pkg/cmd/access-review/campaign/view/view.go +++ b/pkg/cmd/access-review/campaign/view/view.go @@ -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( diff --git a/pkg/cmd/access-review/entry/decide/decide.go b/pkg/cmd/access-review/entry/decide/decide.go index c622e2ac1..d4029b326 100644 --- a/pkg/cmd/access-review/entry/decide/decide.go +++ b/pkg/cmd/access-review/entry/decide/decide.go @@ -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{ diff --git a/pkg/cmd/access-review/entry/decideall/decideall.go b/pkg/cmd/access-review/entry/decideall/decideall.go index 001749ea6..8709ebf58 100644 --- a/pkg/cmd/access-review/entry/decideall/decideall.go +++ b/pkg/cmd/access-review/entry/decideall/decideall.go @@ -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)) diff --git a/pkg/cmd/access-review/entry/list/list.go b/pkg/cmd/access-review/entry/list/list.go index d38fc1df8..047dde27f 100644 --- a/pkg/cmd/access-review/entry/list/list.go +++ b/pkg/cmd/access-review/entry/list/list.go @@ -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{ diff --git a/pkg/cmd/access-review/entry/setflag/flag.go b/pkg/cmd/access-review/entry/setflag/flag.go index 97ac49c56..7fc154bc6 100644 --- a/pkg/cmd/access-review/entry/setflag/flag.go +++ b/pkg/cmd/access-review/entry/setflag/flag.go @@ -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{ diff --git a/pkg/cmd/access-review/source/create/create.go b/pkg/cmd/access-review/source/create/create.go index 9f7f931d1..c21ffaaf0 100644 --- a/pkg/cmd/access-review/source/create/create.go +++ b/pkg/cmd/access-review/source/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/access-review/source/delete/delete.go b/pkg/cmd/access-review/source/delete/delete.go index 5799d39f0..efcfe134c 100644 --- a/pkg/cmd/access-review/source/delete/delete.go +++ b/pkg/cmd/access-review/source/delete/delete.go @@ -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( diff --git a/pkg/cmd/access-review/source/list/list.go b/pkg/cmd/access-review/source/list/list.go index 00f1f8467..8908cf376 100644 --- a/pkg/cmd/access-review/source/list/list.go +++ b/pkg/cmd/access-review/source/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/access-review/source/update/update.go b/pkg/cmd/access-review/source/update/update.go index 0f4d6f5c5..3bd0b99ea 100644 --- a/pkg/cmd/access-review/source/update/update.go +++ b/pkg/cmd/access-review/source/update/update.go @@ -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{ diff --git a/pkg/cmd/access-review/source/view/view.go b/pkg/cmd/access-review/source/view/view.go index a87075dd9..059f423d4 100644 --- a/pkg/cmd/access-review/source/view/view.go +++ b/pkg/cmd/access-review/source/view/view.go @@ -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( diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index d5e26a98d..d11cd03d0 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -77,6 +77,7 @@ func NewCmdAPI(f *cmdutil.Factory) *cobra.Command { hc.Token, endpoint, cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), ) var query string diff --git a/pkg/cmd/auditlog/list/list.go b/pkg/cmd/auditlog/list/list.go index f33c77df6..fa28fd086 100644 --- a/pkg/cmd/auditlog/list/list.go +++ b/pkg/cmd/auditlog/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/auditlog/view/view.go b/pkg/cmd/auditlog/view/view.go index b2a480a67..64e2f5016 100644 --- a/pkg/cmd/auditlog/view/view.go +++ b/pkg/cmd/auditlog/view/view.go @@ -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( diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index cc672e30b..5e9953238 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -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 --org `, + # 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() + } +} diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 201ed36ce..5f5e03c28 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -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, "/") +} diff --git a/pkg/cmd/cmdutil/factory.go b/pkg/cmd/cmdutil/factory.go index 24fdb1a8d..3dd7e17c7 100644 --- a/pkg/cmd/cmdutil/factory.go +++ b/pkg/cmd/cmdutil/factory.go @@ -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() + }, + }) +} diff --git a/pkg/cmd/context/get/get.go b/pkg/cmd/context/get/get.go index d30c8222a..eb2cc0f95 100644 --- a/pkg/cmd/context/get/get.go +++ b/pkg/cmd/context/get/get.go @@ -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( diff --git a/pkg/cmd/context/update/update.go b/pkg/cmd/context/update/update.go index d0fd64b24..adc606a68 100644 --- a/pkg/cmd/context/update/update.go +++ b/pkg/cmd/context/update/update.go @@ -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( diff --git a/pkg/cmd/control/create/create.go b/pkg/cmd/control/create/create.go index bf6b93d3d..0c7ff258c 100644 --- a/pkg/cmd/control/create/create.go +++ b/pkg/cmd/control/create/create.go @@ -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" diff --git a/pkg/cmd/control/delete/delete.go b/pkg/cmd/control/delete/delete.go index 4fd96d54d..a29357a66 100644 --- a/pkg/cmd/control/delete/delete.go +++ b/pkg/cmd/control/delete/delete.go @@ -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( diff --git a/pkg/cmd/control/list/list.go b/pkg/cmd/control/list/list.go index f2882faff..4e49d25f3 100644 --- a/pkg/cmd/control/list/list.go +++ b/pkg/cmd/control/list/list.go @@ -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{ diff --git a/pkg/cmd/control/update/update.go b/pkg/cmd/control/update/update.go index 9ffbf7d90..14fd575b7 100644 --- a/pkg/cmd/control/update/update.go +++ b/pkg/cmd/control/update/update.go @@ -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{ diff --git a/pkg/cmd/control/view/view.go b/pkg/cmd/control/view/view.go index 4c0b30dd9..5118f1661 100644 --- a/pkg/cmd/control/view/view.go +++ b/pkg/cmd/control/view/view.go @@ -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( diff --git a/pkg/cmd/evidence/delete/delete.go b/pkg/cmd/evidence/delete/delete.go index 52e16af13..6360dc965 100644 --- a/pkg/cmd/evidence/delete/delete.go +++ b/pkg/cmd/evidence/delete/delete.go @@ -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( diff --git a/pkg/cmd/evidence/list/list.go b/pkg/cmd/evidence/list/list.go index b43bf8fcc..8c242fe7f 100644 --- a/pkg/cmd/evidence/list/list.go +++ b/pkg/cmd/evidence/list/list.go @@ -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{ diff --git a/pkg/cmd/evidence/view/view.go b/pkg/cmd/evidence/view/view.go index 7dcf0e4b0..a5335bdc6 100644 --- a/pkg/cmd/evidence/view/view.go +++ b/pkg/cmd/evidence/view/view.go @@ -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( diff --git a/pkg/cmd/finding/create/create.go b/pkg/cmd/finding/create/create.go index 382d86552..c1d318506 100644 --- a/pkg/cmd/finding/create/create.go +++ b/pkg/cmd/finding/create/create.go @@ -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{ diff --git a/pkg/cmd/finding/delete/delete.go b/pkg/cmd/finding/delete/delete.go index 5bb6706a7..346bfdce2 100644 --- a/pkg/cmd/finding/delete/delete.go +++ b/pkg/cmd/finding/delete/delete.go @@ -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( diff --git a/pkg/cmd/finding/list/list.go b/pkg/cmd/finding/list/list.go index 776a33e64..bd0889098 100644 --- a/pkg/cmd/finding/list/list.go +++ b/pkg/cmd/finding/list/list.go @@ -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{ diff --git a/pkg/cmd/finding/update/update.go b/pkg/cmd/finding/update/update.go index a907fb9fa..22ae2357e 100644 --- a/pkg/cmd/finding/update/update.go +++ b/pkg/cmd/finding/update/update.go @@ -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{ diff --git a/pkg/cmd/finding/view/view.go b/pkg/cmd/finding/view/view.go index cef9072bb..73d06b5d1 100644 --- a/pkg/cmd/finding/view/view.go +++ b/pkg/cmd/finding/view/view.go @@ -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( diff --git a/pkg/cmd/framework/create/create.go b/pkg/cmd/framework/create/create.go index 1df9c5227..de9212f71 100644 --- a/pkg/cmd/framework/create/create.go +++ b/pkg/cmd/framework/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/framework/delete/delete.go b/pkg/cmd/framework/delete/delete.go index d0948293e..661a67332 100644 --- a/pkg/cmd/framework/delete/delete.go +++ b/pkg/cmd/framework/delete/delete.go @@ -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( diff --git a/pkg/cmd/framework/list/list.go b/pkg/cmd/framework/list/list.go index 1434c39e0..95b0fe6e9 100644 --- a/pkg/cmd/framework/list/list.go +++ b/pkg/cmd/framework/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/framework/update/update.go b/pkg/cmd/framework/update/update.go index 538be2a1c..9596eca6e 100644 --- a/pkg/cmd/framework/update/update.go +++ b/pkg/cmd/framework/update/update.go @@ -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{ diff --git a/pkg/cmd/framework/view/view.go b/pkg/cmd/framework/view/view.go index 3dbe1be02..c7e4a7c04 100644 --- a/pkg/cmd/framework/view/view.go +++ b/pkg/cmd/framework/view/view.go @@ -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( diff --git a/pkg/cmd/org/list/list.go b/pkg/cmd/org/list/list.go index b1069427d..12d5729a4 100644 --- a/pkg/cmd/org/list/list.go +++ b/pkg/cmd/org/list/list.go @@ -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{ diff --git a/pkg/cmd/risk/create/create.go b/pkg/cmd/risk/create/create.go index 881a38a99..dec4b367e 100644 --- a/pkg/cmd/risk/create/create.go +++ b/pkg/cmd/risk/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/risk/delete/delete.go b/pkg/cmd/risk/delete/delete.go index 53c97e06e..3588005ad 100644 --- a/pkg/cmd/risk/delete/delete.go +++ b/pkg/cmd/risk/delete/delete.go @@ -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( diff --git a/pkg/cmd/risk/list/list.go b/pkg/cmd/risk/list/list.go index 9e80d86d4..d11f95a9c 100644 --- a/pkg/cmd/risk/list/list.go +++ b/pkg/cmd/risk/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/risk/update/update.go b/pkg/cmd/risk/update/update.go index 2a050ca33..5db389691 100644 --- a/pkg/cmd/risk/update/update.go +++ b/pkg/cmd/risk/update/update.go @@ -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{ diff --git a/pkg/cmd/risk/view/view.go b/pkg/cmd/risk/view/view.go index 2fa545489..896ef9dd2 100644 --- a/pkg/cmd/risk/view/view.go +++ b/pkg/cmd/risk/view/view.go @@ -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( diff --git a/pkg/cmd/soa/create/create.go b/pkg/cmd/soa/create/create.go index cb369cc86..49afc8d51 100644 --- a/pkg/cmd/soa/create/create.go +++ b/pkg/cmd/soa/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/soa/delete/delete.go b/pkg/cmd/soa/delete/delete.go index 079b051d5..530df1984 100644 --- a/pkg/cmd/soa/delete/delete.go +++ b/pkg/cmd/soa/delete/delete.go @@ -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( diff --git a/pkg/cmd/soa/list/list.go b/pkg/cmd/soa/list/list.go index 4b2f0fde4..e6d31bba0 100644 --- a/pkg/cmd/soa/list/list.go +++ b/pkg/cmd/soa/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/soa/statement/add/add.go b/pkg/cmd/soa/statement/add/add.go index 3663fc09c..6bde9b4b8 100644 --- a/pkg/cmd/soa/statement/add/add.go +++ b/pkg/cmd/soa/statement/add/add.go @@ -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{ diff --git a/pkg/cmd/soa/statement/list/list.go b/pkg/cmd/soa/statement/list/list.go index 5b646d221..e44ef03a1 100644 --- a/pkg/cmd/soa/statement/list/list.go +++ b/pkg/cmd/soa/statement/list/list.go @@ -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{ diff --git a/pkg/cmd/soa/statement/remove/remove.go b/pkg/cmd/soa/statement/remove/remove.go index a559dd452..0c105a2ca 100644 --- a/pkg/cmd/soa/statement/remove/remove.go +++ b/pkg/cmd/soa/statement/remove/remove.go @@ -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( diff --git a/pkg/cmd/soa/statement/update/update.go b/pkg/cmd/soa/statement/update/update.go index 6984ca1d1..4221c8ea1 100644 --- a/pkg/cmd/soa/statement/update/update.go +++ b/pkg/cmd/soa/statement/update/update.go @@ -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{ diff --git a/pkg/cmd/soa/update/update.go b/pkg/cmd/soa/update/update.go index 28f6e2ffd..d4b943bda 100644 --- a/pkg/cmd/soa/update/update.go +++ b/pkg/cmd/soa/update/update.go @@ -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{ diff --git a/pkg/cmd/soa/view/view.go b/pkg/cmd/soa/view/view.go index b007a1685..991d60b4e 100644 --- a/pkg/cmd/soa/view/view.go +++ b/pkg/cmd/soa/view/view.go @@ -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( diff --git a/pkg/cmd/user/list/list.go b/pkg/cmd/user/list/list.go index f77000190..30f4b51b1 100644 --- a/pkg/cmd/user/list/list.go +++ b/pkg/cmd/user/list/list.go @@ -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 == "" { diff --git a/pkg/cmd/user/view/view.go b/pkg/cmd/user/view/view.go index 5057bcaee..d6c23cbe7 100644 --- a/pkg/cmd/user/view/view.go +++ b/pkg/cmd/user/view/view.go @@ -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( diff --git a/pkg/cmd/webhook/create/create.go b/pkg/cmd/webhook/create/create.go index bf52b8227..e3cde9ac6 100644 --- a/pkg/cmd/webhook/create/create.go +++ b/pkg/cmd/webhook/create/create.go @@ -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 == "" { diff --git a/pkg/cmd/webhook/delete/delete.go b/pkg/cmd/webhook/delete/delete.go index fbdab3132..44a09aeb5 100644 --- a/pkg/cmd/webhook/delete/delete.go +++ b/pkg/cmd/webhook/delete/delete.go @@ -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( diff --git a/pkg/cmd/webhook/event/list/list.go b/pkg/cmd/webhook/event/list/list.go index fad9c13dd..290d9aaf7 100644 --- a/pkg/cmd/webhook/event/list/list.go +++ b/pkg/cmd/webhook/event/list/list.go @@ -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{ diff --git a/pkg/cmd/webhook/list/list.go b/pkg/cmd/webhook/list/list.go index 195d54a37..f69f42904 100644 --- a/pkg/cmd/webhook/list/list.go +++ b/pkg/cmd/webhook/list/list.go @@ -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{} diff --git a/pkg/cmd/webhook/update/update.go b/pkg/cmd/webhook/update/update.go index 5a9f79e5b..886361236 100644 --- a/pkg/cmd/webhook/update/update.go +++ b/pkg/cmd/webhook/update/update.go @@ -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( diff --git a/pkg/cmd/webhook/view/view.go b/pkg/cmd/webhook/view/view.go index c8715565e..807707d98 100644 --- a/pkg/cmd/webhook/view/view.go +++ b/pkg/cmd/webhook/view/view.go @@ -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( diff --git a/pkg/coredata/electronic_signature.go b/pkg/coredata/electronic_signature.go index d02c3919b..25dd99e36 100644 --- a/pkg/coredata/electronic_signature.go +++ b/pkg/coredata/electronic_signature.go @@ -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( diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 7d8949b11..cf31b102b 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -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 } diff --git a/pkg/coredata/migrations/20260406T112100Z.sql b/pkg/coredata/migrations/20260406T112100Z.sql new file mode 100644 index 000000000..f531ac408 --- /dev/null +++ b/pkg/coredata/migrations/20260406T112100Z.sql @@ -0,0 +1,107 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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 +); diff --git a/pkg/coredata/migrations/20260406T112200Z.sql b/pkg/coredata/migrations/20260406T112200Z.sql new file mode 100644 index 000000000..b82701ebe --- /dev/null +++ b/pkg/coredata/migrations/20260406T112200Z.sql @@ -0,0 +1,2 @@ +ALTER TABLE iam_oauth2_consents + ADD COLUMN session_id TEXT NOT NULL REFERENCES iam_sessions(id); diff --git a/pkg/coredata/migrations/20260411T120000Z.sql b/pkg/coredata/migrations/20260411T120000Z.sql new file mode 100644 index 000000000..7a743d1e3 --- /dev/null +++ b/pkg/coredata/migrations/20260411T120000Z.sql @@ -0,0 +1,49 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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() +); diff --git a/pkg/coredata/migrations/20260413T232000Z.sql b/pkg/coredata/migrations/20260413T232000Z.sql new file mode 100644 index 000000000..c9878d1d9 --- /dev/null +++ b/pkg/coredata/migrations/20260413T232000Z.sql @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/migrations/20260414T083800Z.sql b/pkg/coredata/migrations/20260414T083800Z.sql new file mode 100644 index 000000000..d3f92bd12 --- /dev/null +++ b/pkg/coredata/migrations/20260414T083800Z.sql @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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; diff --git a/pkg/coredata/migrations/20260414T140000Z.sql b/pkg/coredata/migrations/20260414T140000Z.sql new file mode 100644 index 000000000..8ce57ef37 --- /dev/null +++ b/pkg/coredata/migrations/20260414T140000Z.sql @@ -0,0 +1,17 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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; diff --git a/pkg/coredata/migrations/20260416T120000Z.sql b/pkg/coredata/migrations/20260416T120000Z.sql new file mode 100644 index 000000000..6d97dd379 --- /dev/null +++ b/pkg/coredata/migrations/20260416T120000Z.sql @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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; diff --git a/pkg/coredata/oauth2_access_token.go b/pkg/coredata/oauth2_access_token.go new file mode 100644 index 000000000..10b7ae5ff --- /dev/null +++ b/pkg/coredata/oauth2_access_token.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_authorization_code.go b/pkg/coredata/oauth2_authorization_code.go new file mode 100644 index 000000000..018e5955f --- /dev/null +++ b/pkg/coredata/oauth2_authorization_code.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_claim.go b/pkg/coredata/oauth2_claim.go new file mode 100644 index 000000000..cfef14eff --- /dev/null +++ b/pkg/coredata/oauth2_claim.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_client.go b/pkg/coredata/oauth2_client.go new file mode 100644 index 000000000..3fd3f78d3 --- /dev/null +++ b/pkg/coredata/oauth2_client.go @@ -0,0 +1,384 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_client_order_field.go b/pkg/coredata/oauth2_client_order_field.go new file mode 100644 index 000000000..27b991194 --- /dev/null +++ b/pkg/coredata/oauth2_client_order_field.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_client_token_endpoint_auth_method.go b/pkg/coredata/oauth2_client_token_endpoint_auth_method.go new file mode 100644 index 000000000..8909b5adb --- /dev/null +++ b/pkg/coredata/oauth2_client_token_endpoint_auth_method.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_client_visibility.go b/pkg/coredata/oauth2_client_visibility.go new file mode 100644 index 000000000..83a2b460f --- /dev/null +++ b/pkg/coredata/oauth2_client_visibility.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_code_challenge_method.go b/pkg/coredata/oauth2_code_challenge_method.go new file mode 100644 index 000000000..4478e2682 --- /dev/null +++ b/pkg/coredata/oauth2_code_challenge_method.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/oauth2_consent.go b/pkg/coredata/oauth2_consent.go new file mode 100644 index 000000000..5e6a0aac3 --- /dev/null +++ b/pkg/coredata/oauth2_consent.go @@ -0,0 +1,497 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/uri" +) + +type ( + OAuth2Consent struct { + ID gid.GID `db:"id"` + IdentityID gid.GID `db:"identity_id"` + SessionID gid.GID `db:"session_id"` + ClientID gid.GID `db:"client_id"` + Scopes OAuth2Scopes `db:"scopes"` + RedirectURI *uri.URI `db:"redirect_uri"` + CodeChallenge string `db:"code_challenge"` + CodeChallengeMethod OAuth2CodeChallengeMethod `db:"code_challenge_method"` + Nonce string `db:"nonce"` + State string `db:"state"` + DeviceCodeID *gid.GID `db:"device_code_id"` + Approved bool `db:"approved"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + OAuth2Consents []*OAuth2Consent +) + +func (c *OAuth2Consent) CursorKey(orderBy OAuth2ConsentOrderField) page.CursorKey { + switch orderBy { + case OAuth2ConsentOrderFieldCreatedAt: + return page.NewCursorKey(c.ID, c.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (c *OAuth2Consent) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := ` +SELECT + identity_id, + session_id +FROM + iam_oauth2_consents +WHERE + id = $1 +LIMIT 1; +` + + var identityID, sessionID gid.GID + if err := conn.QueryRow(ctx, q, c.ID).Scan(&identityID, &sessionID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + + return nil, fmt.Errorf("cannot query oauth2_consent authorization attributes: %w", err) + } + + return map[string]string{ + "identity_id": identityID.String(), + "session_id": sessionID.String(), + }, nil +} + +func (c *OAuth2Consent) LoadByID( + ctx context.Context, + conn pg.Querier, + id gid.GID, +) error { + q := ` +SELECT + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +FROM + iam_oauth2_consents +WHERE + id = @id +LIMIT 1; +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"id": id}) + if err != nil { + return fmt.Errorf("cannot query oauth2_consent: %w", err) + } + + consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_consent: %w", err) + } + + *c = consent + return nil +} + +func (c *OAuth2Consent) LoadByIDForSession( + ctx context.Context, + conn pg.Querier, + id gid.GID, + identityID gid.GID, + sessionID gid.GID, +) error { + q := ` +SELECT + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +FROM + iam_oauth2_consents +WHERE + id = @id + AND identity_id = @identity_id + AND session_id = @session_id +LIMIT 1; +` + + rows, err := conn.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "id": id, + "identity_id": identityID, + "session_id": sessionID, + }, + ) + if err != nil { + return fmt.Errorf("cannot query oauth2_consent: %w", err) + } + + consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_consent: %w", err) + } + + *c = consent + return nil +} + +func (c *OAuth2Consent) LoadByIDForSessionForUpdate( + ctx context.Context, + conn pg.Querier, + id gid.GID, + identityID gid.GID, + sessionID gid.GID, +) error { + q := ` +SELECT + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +FROM + iam_oauth2_consents +WHERE + id = @id + AND identity_id = @identity_id + AND session_id = @session_id +LIMIT 1 +FOR UPDATE; +` + + rows, err := conn.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "id": id, + "identity_id": identityID, + "session_id": sessionID, + }, + ) + if err != nil { + return fmt.Errorf("cannot query oauth2_consent: %w", err) + } + + consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_consent: %w", err) + } + + *c = consent + return nil +} + +func (c *OAuth2Consent) LoadMatchingConsent( + ctx context.Context, + conn pg.Querier, + identityID gid.GID, + clientID gid.GID, + scopes OAuth2Scopes, +) error { + q := ` +SELECT + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +FROM + iam_oauth2_consents +WHERE + identity_id = @identity_id + AND client_id = @client_id + AND approved = TRUE + AND scopes @> @scopes + AND scopes <@ @scopes +LIMIT 1; +` + + rows, err := conn.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "identity_id": identityID, + "client_id": clientID, + "scopes": scopes, + }, + ) + if err != nil { + return fmt.Errorf("cannot query oauth2_consent: %w", err) + } + + consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_consent: %w", err) + } + + *c = consent + return nil +} + +func (c *OAuth2Consent) Insert(ctx context.Context, conn pg.Tx) error { + q := ` +INSERT INTO iam_oauth2_consents ( + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +) VALUES ( + @id, + @identity_id, + @session_id, + @client_id, + @scopes, + @redirect_uri, + @code_challenge, + @code_challenge_method, + @nonce, + @state, + @device_code_id, + @approved, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": c.ID, + "identity_id": c.IdentityID, + "session_id": c.SessionID, + "client_id": c.ClientID, + "scopes": c.Scopes, + "redirect_uri": c.RedirectURI, + "code_challenge": c.CodeChallenge, + "code_challenge_method": c.CodeChallengeMethod, + "nonce": c.Nonce, + "state": c.State, + "device_code_id": c.DeviceCodeID, + "approved": c.Approved, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot insert oauth2_consent: %w", err) + } + + return nil +} + +func (c *OAuth2Consent) Update(ctx context.Context, conn pg.Tx) error { + q := ` +UPDATE iam_oauth2_consents +SET + scopes = @scopes, + approved = @approved, + updated_at = @updated_at +WHERE + id = @id +` + + _, err := conn.Exec( + ctx, + q, + pgx.StrictNamedArgs{ + "id": c.ID, + "scopes": c.Scopes, + "approved": c.Approved, + "updated_at": c.UpdatedAt, + }, + ) + if err != nil { + return fmt.Errorf("cannot update oauth2_consent: %w", err) + } + + return nil +} + +func (c *OAuth2Consent) Delete(ctx context.Context, conn pg.Tx) error { + q := ` +DELETE FROM iam_oauth2_consents +WHERE + id = @id +` + + _, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": c.ID}) + if err != nil { + return fmt.Errorf("cannot delete oauth2_consent: %w", err) + } + + return nil +} + +func (c *OAuth2Consents) LoadByIdentityID( + ctx context.Context, + conn pg.Querier, + identityID gid.GID, + cursor *page.Cursor[OAuth2ConsentOrderField], +) error { + q := ` +SELECT + id, + identity_id, + session_id, + client_id, + scopes, + redirect_uri, + code_challenge, + code_challenge_method, + nonce, + state, + device_code_id, + approved, + created_at, + updated_at +FROM + iam_oauth2_consents +WHERE + identity_id = @identity_id + AND approved = TRUE + AND %s +` + + q = fmt.Sprintf( + q, + cursor.SQLFragment(), + ) + + args := pgx.StrictNamedArgs{"identity_id": identityID} + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query oauth2_consents: %w", err) + } + + consents, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[OAuth2Consent]) + if err != nil { + return fmt.Errorf("cannot collect oauth2_consents: %w", err) + } + + *c = consents + return nil +} + +func (c *OAuth2Consents) CountByIdentityID( + ctx context.Context, + conn pg.Querier, + identityID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + iam_oauth2_consents +WHERE + identity_id = @identity_id + AND approved = TRUE; +` + + var count int + err := conn.QueryRow( + ctx, + q, + pgx.StrictNamedArgs{"identity_id": identityID}, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("cannot count oauth2_consents: %w", err) + } + + return count, nil +} diff --git a/pkg/coredata/oauth2_consent_order_field.go b/pkg/coredata/oauth2_consent_order_field.go new file mode 100644 index 000000000..e2a60b3bf --- /dev/null +++ b/pkg/coredata/oauth2_consent_order_field.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 OAuth2ConsentOrderField string + +const ( + OAuth2ConsentOrderFieldCreatedAt OAuth2ConsentOrderField = "CREATED_AT" +) + +func (f OAuth2ConsentOrderField) Column() string { + switch f { + case OAuth2ConsentOrderFieldCreatedAt: + return "created_at" + } + + panic(fmt.Sprintf("unsupported order by: %s", f)) +} + +func (f OAuth2ConsentOrderField) IsValid() bool { + switch f { + case OAuth2ConsentOrderFieldCreatedAt: + return true + } + + return false +} + +func (f OAuth2ConsentOrderField) String() string { return string(f) } + +func (f *OAuth2ConsentOrderField) UnmarshalText(text []byte) error { + *f = OAuth2ConsentOrderField(text) + if !f.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2ConsentOrderField", string(text)) + } + + return nil +} + +func (f OAuth2ConsentOrderField) MarshalText() ([]byte, error) { + return []byte(f.String()), nil +} diff --git a/pkg/coredata/oauth2_device_code.go b/pkg/coredata/oauth2_device_code.go new file mode 100644 index 000000000..6afcaeb8d --- /dev/null +++ b/pkg/coredata/oauth2_device_code.go @@ -0,0 +1,308 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + // OAuth2UserCode represents a raw 8-character user code for the device flow. + OAuth2UserCode string + + OAuth2DeviceCode struct { + ID gid.GID `db:"id"` + DeviceCodeHash []byte `db:"device_code_hash"` + UserCode OAuth2UserCode `db:"user_code"` + ClientID gid.GID `db:"client_id"` + Scopes OAuth2Scopes `db:"scopes"` + IdentityID *gid.GID `db:"identity_id"` + Status OAuth2DeviceCodeStatus `db:"status"` + LastPolledAt *time.Time `db:"last_polled_at"` + PollInterval int `db:"poll_interval"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + } +) + +// Format returns the user code formatted as XXXX-XXXX for display. +func (c OAuth2UserCode) Format() string { + if len(c) != 8 { + panic(fmt.Sprintf("invalid user code length: %d", len(c))) + } + + return string(c[:4]) + "-" + string(c[4:]) +} + +func (d *OAuth2DeviceCode) Insert(ctx context.Context, conn pg.Tx) error { + q := ` +INSERT INTO iam_oauth2_device_codes ( + id, + device_code_hash, + user_code, + client_id, + scopes, + identity_id, + status, + last_polled_at, + poll_interval, + created_at, + expires_at +) VALUES ( + @id, + @device_code_hash, + @user_code, + @client_id, + @scopes, + @identity_id, + @status, + @last_polled_at, + @poll_interval, + @created_at, + @expires_at +) +` + + args := pgx.StrictNamedArgs{ + "id": d.ID, + "device_code_hash": d.DeviceCodeHash, + "user_code": d.UserCode, + "client_id": d.ClientID, + "scopes": d.Scopes, + "identity_id": d.IdentityID, + "status": d.Status, + "last_polled_at": d.LastPolledAt, + "poll_interval": d.PollInterval, + "created_at": d.CreatedAt, + "expires_at": d.ExpiresAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && + pgErr.Code == "23505" && + pgErr.ConstraintName == "iam_oauth2_device_codes_user_code_unique" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot insert oauth2_device_code: %w", err) + } + + return nil +} + +func (d *OAuth2DeviceCode) LoadByIDForUpdate( + ctx context.Context, + conn pg.Tx, + id gid.GID, +) error { + q := ` +SELECT + id, + device_code_hash, + user_code, + client_id, + scopes, + identity_id, + status, + last_polled_at, + poll_interval, + created_at, + expires_at +FROM + iam_oauth2_device_codes +WHERE + id = @id +FOR UPDATE; +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"id": id}) + if err != nil { + return fmt.Errorf("cannot query oauth2_device_code: %w", err) + } + + code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_device_code: %w", err) + } + + *d = code + return nil +} + +func (d *OAuth2DeviceCode) LoadByUserCodeForUpdate( + ctx context.Context, + conn pg.Tx, + userCode string, +) error { + q := ` +SELECT + id, + device_code_hash, + user_code, + client_id, + scopes, + identity_id, + status, + last_polled_at, + poll_interval, + created_at, + expires_at +FROM + iam_oauth2_device_codes +WHERE + user_code = @user_code +FOR UPDATE; +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"user_code": userCode}) + if err != nil { + return fmt.Errorf("cannot query oauth2_device_code: %w", err) + } + + code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_device_code: %w", err) + } + + *d = code + return nil +} + +func (d *OAuth2DeviceCode) LoadByDeviceCodeHashForUpdate( + ctx context.Context, + conn pg.Querier, + hashedValue []byte, + clientID gid.GID, +) error { + q := ` +SELECT + id, + device_code_hash, + user_code, + client_id, + scopes, + identity_id, + status, + last_polled_at, + poll_interval, + created_at, + expires_at +FROM + iam_oauth2_device_codes +WHERE + device_code_hash = @device_code_hash + AND client_id = @client_id +FOR UPDATE; +` + + rows, err := conn.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "device_code_hash": hashedValue, + "client_id": clientID, + }, + ) + if err != nil { + return fmt.Errorf("cannot query oauth2_device_code: %w", err) + } + + code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_device_code: %w", err) + } + + *d = code + return nil +} + +func (d *OAuth2DeviceCode) Update(ctx context.Context, conn pg.Tx) error { + q := ` +UPDATE iam_oauth2_device_codes +SET + status = @status, + identity_id = @identity_id, + last_polled_at = @last_polled_at, + poll_interval = @poll_interval +WHERE + id = @id +` + + args := pgx.StrictNamedArgs{ + "id": d.ID, + "status": d.Status, + "identity_id": d.IdentityID, + "last_polled_at": d.LastPolledAt, + "poll_interval": d.PollInterval, + } + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot update oauth2_device_code: %w", err) + } + + return nil +} + +func (d *OAuth2DeviceCode) Delete(ctx context.Context, conn pg.Tx) error { + q := ` +DELETE FROM iam_oauth2_device_codes +WHERE + id = @id +` + + args := pgx.StrictNamedArgs{"id": d.ID} + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot delete oauth2_device_code: %w", err) + } + + return nil +} + +func (d *OAuth2DeviceCode) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) { + q := ` +DELETE FROM iam_oauth2_device_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_device_codes: %w", err) + } + + return result.RowsAffected(), nil +} diff --git a/pkg/coredata/oauth2_device_code_status.go b/pkg/coredata/oauth2_device_code_status.go new file mode 100644 index 000000000..d03510be4 --- /dev/null +++ b/pkg/coredata/oauth2_device_code_status.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 OAuth2DeviceCodeStatus string + +const ( + OAuth2DeviceCodeStatusPending OAuth2DeviceCodeStatus = "pending" + OAuth2DeviceCodeStatusAuthorized OAuth2DeviceCodeStatus = "authorized" + OAuth2DeviceCodeStatusDenied OAuth2DeviceCodeStatus = "denied" + OAuth2DeviceCodeStatusExpired OAuth2DeviceCodeStatus = "expired" +) + +func (s OAuth2DeviceCodeStatus) IsValid() bool { + switch s { + case OAuth2DeviceCodeStatusPending, + OAuth2DeviceCodeStatusAuthorized, + OAuth2DeviceCodeStatusDenied, + OAuth2DeviceCodeStatusExpired: + return true + } + + return false +} + +func (s OAuth2DeviceCodeStatus) String() string { return string(s) } + +func (s *OAuth2DeviceCodeStatus) UnmarshalText(text []byte) error { + *s = OAuth2DeviceCodeStatus(text) + if !s.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2DeviceCodeStatus", string(text)) + } + + return nil +} + +func (s OAuth2DeviceCodeStatus) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} diff --git a/pkg/coredata/oauth2_device_code_test.go b/pkg/coredata/oauth2_device_code_test.go new file mode 100644 index 000000000..11584fa74 --- /dev/null +++ b/pkg/coredata/oauth2_device_code_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestOAuth2UserCode_Format(t *testing.T) { + t.Parallel() + + t.Run( + "formats as XXXX-XXXX", + func(t *testing.T) { + t.Parallel() + + code := coredata.OAuth2UserCode("ABCDEFGH") + assert.Equal(t, "ABCD-EFGH", code.Format()) + }, + ) + + t.Run( + "panics on short code", + func(t *testing.T) { + t.Parallel() + + code := coredata.OAuth2UserCode("ABC") + assert.Panics(t, func() { code.Format() }) + }, + ) + + t.Run( + "panics on long code", + func(t *testing.T) { + t.Parallel() + + code := coredata.OAuth2UserCode("ABCDEFGHIJ") + assert.Panics(t, func() { code.Format() }) + }, + ) + + t.Run( + "panics on empty code", + func(t *testing.T) { + t.Parallel() + + code := coredata.OAuth2UserCode("") + assert.Panics(t, func() { code.Format() }) + }, + ) +} diff --git a/pkg/coredata/oauth2_grant_type.go b/pkg/coredata/oauth2_grant_type.go new file mode 100644 index 000000000..7d8003bb6 --- /dev/null +++ b/pkg/coredata/oauth2_grant_type.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + OAuth2GrantType string + OAuth2GrantTypes []OAuth2GrantType +) + +const ( + OAuth2GrantTypeAuthorizationCode OAuth2GrantType = "authorization_code" + OAuth2GrantTypeRefreshToken OAuth2GrantType = "refresh_token" + OAuth2GrantTypeDeviceCode OAuth2GrantType = "urn:ietf:params:oauth:grant-type:device_code" +) + +func (g OAuth2GrantType) IsValid() bool { + switch g { + case OAuth2GrantTypeAuthorizationCode, + OAuth2GrantTypeRefreshToken, + OAuth2GrantTypeDeviceCode: + return true + } + + return false +} + +func (g OAuth2GrantType) String() string { return string(g) } + +func (g *OAuth2GrantType) UnmarshalText(text []byte) error { + *g = OAuth2GrantType(text) + if !g.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2GrantType", string(text)) + } + + return nil +} + +func (g OAuth2GrantType) MarshalText() ([]byte, error) { + return []byte(g.String()), nil +} diff --git a/pkg/coredata/oauth2_refresh_token.go b/pkg/coredata/oauth2_refresh_token.go new file mode 100644 index 000000000..26b656d77 --- /dev/null +++ b/pkg/coredata/oauth2_refresh_token.go @@ -0,0 +1,344 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + OAuth2RefreshToken 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"` + AccessTokenID gid.GID `db:"access_token_id"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + RevokedAt *time.Time `db:"revoked_at"` + } +) + +func (t *OAuth2RefreshToken) Insert(ctx context.Context, conn pg.Tx) error { + q := ` +INSERT INTO iam_oauth2_refresh_tokens ( + id, + hashed_value, + client_id, + identity_id, + scopes, + access_token_id, + created_at, + expires_at, + revoked_at +) VALUES ( + @id, + @hashed_value, + @client_id, + @identity_id, + @scopes, + @access_token_id, + @created_at, + @expires_at, + @revoked_at +) +` + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "hashed_value": t.HashedValue, + "client_id": t.ClientID, + "identity_id": t.IdentityID, + "scopes": t.Scopes, + "access_token_id": t.AccessTokenID, + "created_at": t.CreatedAt, + "expires_at": t.ExpiresAt, + "revoked_at": t.RevokedAt, + } + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot insert oauth2_refresh_token: %w", err) + } + + return nil +} + +func (t *OAuth2RefreshToken) LoadByHashedValue( + ctx context.Context, + conn pg.Querier, + hashedValue []byte, +) error { + q := ` +SELECT + id, + hashed_value, + client_id, + identity_id, + scopes, + access_token_id, + created_at, + expires_at, + revoked_at +FROM + iam_oauth2_refresh_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_refresh_token: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err) + } + + *t = token + return nil +} + +func (t *OAuth2RefreshToken) LoadByHashedValueAndClientID( + ctx context.Context, + conn pg.Querier, + hashedValue []byte, + clientID gid.GID, +) error { + q := ` +SELECT + id, + hashed_value, + client_id, + identity_id, + scopes, + access_token_id, + created_at, + expires_at, + revoked_at +FROM + iam_oauth2_refresh_tokens +WHERE + hashed_value = @hashed_value + AND client_id = @client_id +LIMIT 1; +` + + rows, err := conn.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "hashed_value": hashedValue, + "client_id": clientID, + }, + ) + if err != nil { + return fmt.Errorf("cannot query oauth2_refresh_token: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err) + } + + *t = token + return nil +} + +func (t *OAuth2RefreshToken) LoadByHashedValueForUpdate( + ctx context.Context, + conn pg.Tx, + hashedValue []byte, + clientID gid.GID, +) error { + q := ` +SELECT + id, + hashed_value, + client_id, + identity_id, + scopes, + access_token_id, + created_at, + expires_at, + revoked_at +FROM + iam_oauth2_refresh_tokens +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_refresh_token: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err) + } + + *t = token + return nil +} + +func (t *OAuth2RefreshToken) Revoke( + ctx context.Context, + conn pg.Tx, + now time.Time, +) error { + q := ` +UPDATE iam_oauth2_refresh_tokens +SET + revoked_at = @revoked_at +WHERE + id = @id +` + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "revoked_at": now, + } + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot revoke oauth2_refresh_token: %w", err) + } + + return nil +} + +func (t *OAuth2RefreshToken) RevokeByClientAndIdentity( + ctx context.Context, + conn pg.Tx, + clientID gid.GID, + identityID gid.GID, + now time.Time, +) (int64, error) { + q := ` +UPDATE iam_oauth2_refresh_tokens +SET + revoked_at = @revoked_at +WHERE + client_id = @client_id + AND identity_id = @identity_id + AND revoked_at IS NULL +` + + result, err := conn.Exec( + ctx, + q, + pgx.StrictNamedArgs{ + "client_id": clientID, + "identity_id": identityID, + "revoked_at": now, + }, + ) + if err != nil { + return 0, fmt.Errorf("cannot revoke oauth2_refresh_tokens by client and identity: %w", err) + } + + return result.RowsAffected(), nil +} + +func (t *OAuth2RefreshToken) RevokeByAccessTokenID( + ctx context.Context, + conn pg.Tx, + accessTokenID gid.GID, + now time.Time, +) (int64, error) { + q := ` +UPDATE iam_oauth2_refresh_tokens +SET + revoked_at = @revoked_at +WHERE + access_token_id = @access_token_id + AND revoked_at IS NULL +` + + result, err := conn.Exec( + ctx, + q, + pgx.StrictNamedArgs{ + "access_token_id": accessTokenID, + "revoked_at": now, + }, + ) + if err != nil { + return 0, fmt.Errorf("cannot revoke oauth2_refresh_tokens by access_token_id: %w", err) + } + + return result.RowsAffected(), nil +} + +func (t *OAuth2RefreshToken) DeleteExpired( + ctx context.Context, + conn pg.Tx, + now time.Time, +) (int64, error) { + q := ` +DELETE FROM iam_oauth2_refresh_tokens +WHERE + expires_at < @now + OR (revoked_at IS NOT NULL AND revoked_at < @revoked_cutoff) +` + + result, err := conn.Exec( + ctx, + q, + pgx.StrictNamedArgs{ + "now": now, + "revoked_cutoff": now.Add(-7 * 24 * time.Hour), + }, + ) + if err != nil { + return 0, fmt.Errorf("cannot delete expired oauth2_refresh_tokens: %w", err) + } + + return result.RowsAffected(), nil +} diff --git a/pkg/coredata/oauth2_response_type.go b/pkg/coredata/oauth2_response_type.go new file mode 100644 index 000000000..1e2a58843 --- /dev/null +++ b/pkg/coredata/oauth2_response_type.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + OAuth2ResponseType string + OAuth2ResponseTypes []OAuth2ResponseType +) + +const ( + OAuth2ResponseTypeCode OAuth2ResponseType = "code" +) + +func (r OAuth2ResponseType) IsValid() bool { + switch r { + case OAuth2ResponseTypeCode: + return true + } + + return false +} + +func (r OAuth2ResponseType) String() string { return string(r) } + +func (r *OAuth2ResponseType) UnmarshalText(text []byte) error { + *r = OAuth2ResponseType(text) + if !r.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2ResponseType", string(text)) + } + + return nil +} + +func (r OAuth2ResponseType) MarshalText() ([]byte, error) { + return []byte(r.String()), nil +} diff --git a/pkg/coredata/oauth2_scope.go b/pkg/coredata/oauth2_scope.go new file mode 100644 index 000000000..3afba2def --- /dev/null +++ b/pkg/coredata/oauth2_scope.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "iter" + "slices" + "strings" +) + +type ( + OAuth2Scope string + OAuth2Scopes []OAuth2Scope +) + +const ( + OAuth2ScopeOpenID OAuth2Scope = "openid" + OAuth2ScopeProfile OAuth2Scope = "profile" + OAuth2ScopeEmail OAuth2Scope = "email" + OAuth2ScopeOfflineAccess OAuth2Scope = "offline_access" +) + +func (s OAuth2Scope) IsValid() bool { + switch s { + case OAuth2ScopeOpenID, OAuth2ScopeProfile, OAuth2ScopeEmail, OAuth2ScopeOfflineAccess: + return true + } + + return false +} + +func (s OAuth2Scope) String() string { return string(s) } + +func (s *OAuth2Scope) UnmarshalText(text []byte) error { + *s = OAuth2Scope(text) + if !s.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2Scope", string(text)) + } + + return nil +} + +func (s OAuth2Scope) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} + +func (s OAuth2Scopes) All() iter.Seq2[int, OAuth2Scope] { + return slices.All(s) +} + +func (s OAuth2Scopes) Values() iter.Seq[OAuth2Scope] { + return slices.Values(s) +} + +func (s OAuth2Scopes) Contains(scope OAuth2Scope) bool { + return slices.Contains(s, scope) +} + +func (s OAuth2Scopes) ContainsAll(seq iter.Seq[OAuth2Scope]) bool { + for scope := range seq { + if !s.Contains(scope) { + return false + } + } + + return true +} + +func (s OAuth2Scopes) String() string { + ss := make([]string, len(s)) + for i, scope := range s { + ss[i] = scope.String() + } + + return strings.Join(ss, " ") +} + +func (s OAuth2Scopes) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} + +func (s OAuth2Scopes) OrDefault(defaultScopes OAuth2Scopes) OAuth2Scopes { + if len(s) == 0 { + return defaultScopes + } + return s +} + +func (s *OAuth2Scopes) UnmarshalText(text []byte) error { + str := string(text) + if str == "" { + *s = nil + return nil + } + + fields := strings.Fields(str) + scopes := make(OAuth2Scopes, len(fields)) + for i, f := range fields { + if err := scopes[i].UnmarshalText([]byte(f)); err != nil { + return err + } + } + + *s = scopes + return nil +} diff --git a/pkg/coredata/oauth2_scope_test.go b/pkg/coredata/oauth2_scope_test.go new file mode 100644 index 000000000..0ab594ce4 --- /dev/null +++ b/pkg/coredata/oauth2_scope_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestOAuth2Scope_IsValid(t *testing.T) { + t.Parallel() + + t.Run( + "offline_access is valid", + func(t *testing.T) { + t.Parallel() + + assert.True(t, coredata.OAuth2ScopeOfflineAccess.IsValid()) + }, + ) + + t.Run( + "unknown scope is invalid", + func(t *testing.T) { + t.Parallel() + + assert.False(t, coredata.OAuth2Scope("admin").IsValid()) + }, + ) +} + +func TestOAuth2Scope_UnmarshalText(t *testing.T) { + t.Parallel() + + t.Run( + "offline_access unmarshals", + func(t *testing.T) { + t.Parallel() + + var scope coredata.OAuth2Scope + err := scope.UnmarshalText([]byte("offline_access")) + assert.NoError(t, err) + assert.Equal(t, coredata.OAuth2ScopeOfflineAccess, scope) + }, + ) + + t.Run( + "invalid scope returns error", + func(t *testing.T) { + t.Parallel() + + var scope coredata.OAuth2Scope + err := scope.UnmarshalText([]byte("admin")) + assert.Error(t, err) + }, + ) +} + +func TestOAuth2Scopes_Contains(t *testing.T) { + t.Parallel() + + t.Run( + "contains offline_access", + func(t *testing.T) { + t.Parallel() + + scopes := coredata.OAuth2Scopes{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeOfflineAccess, + } + assert.True(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess)) + }, + ) + + t.Run( + "does not contain offline_access", + func(t *testing.T) { + t.Parallel() + + scopes := coredata.OAuth2Scopes{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeProfile, + } + assert.False(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess)) + }, + ) +} + +func TestOAuth2Scopes_OrDefault(t *testing.T) { + t.Parallel() + + defaultScopes := coredata.OAuth2Scopes{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeProfile, + } + + t.Run( + "returns default when scopes is nil", + func(t *testing.T) { + t.Parallel() + + var scopes coredata.OAuth2Scopes + result := scopes.OrDefault(defaultScopes) + assert.Equal(t, defaultScopes, result) + }, + ) + + t.Run( + "returns default when scopes is empty", + func(t *testing.T) { + t.Parallel() + + scopes := coredata.OAuth2Scopes{} + result := scopes.OrDefault(defaultScopes) + assert.Equal(t, defaultScopes, result) + }, + ) + + t.Run( + "returns scopes when non-empty", + func(t *testing.T) { + t.Parallel() + + scopes := coredata.OAuth2Scopes{coredata.OAuth2ScopeEmail} + result := scopes.OrDefault(defaultScopes) + assert.Equal(t, scopes, result) + }, + ) +} diff --git a/pkg/coredata/oauth2_signing_algorithm.go b/pkg/coredata/oauth2_signing_algorithm.go new file mode 100644 index 000000000..b2a1f2a1f --- /dev/null +++ b/pkg/coredata/oauth2_signing_algorithm.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 OAuth2SigningAlgorithm string + +const ( + OAuth2SigningAlgorithmRS256 OAuth2SigningAlgorithm = "RS256" +) + +func (a OAuth2SigningAlgorithm) IsValid() bool { + switch a { + case OAuth2SigningAlgorithmRS256: + return true + } + + return false +} + +func (a OAuth2SigningAlgorithm) String() string { return string(a) } + +func (a *OAuth2SigningAlgorithm) UnmarshalText(text []byte) error { + *a = OAuth2SigningAlgorithm(text) + if !a.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2SigningAlgorithm", string(text)) + } + + return nil +} + +func (a OAuth2SigningAlgorithm) MarshalText() ([]byte, error) { + return []byte(a.String()), nil +} diff --git a/pkg/coredata/oauth2_subject_type.go b/pkg/coredata/oauth2_subject_type.go new file mode 100644 index 000000000..8b42b3622 --- /dev/null +++ b/pkg/coredata/oauth2_subject_type.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 OAuth2SubjectType string + +const ( + OAuth2SubjectTypePublic OAuth2SubjectType = "public" +) + +func (s OAuth2SubjectType) IsValid() bool { + switch s { + case OAuth2SubjectTypePublic: + return true + } + + return false +} + +func (s OAuth2SubjectType) String() string { return string(s) } + +func (s *OAuth2SubjectType) UnmarshalText(text []byte) error { + *s = OAuth2SubjectType(text) + if !s.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2SubjectType", string(text)) + } + + return nil +} + +func (s OAuth2SubjectType) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} diff --git a/pkg/coredata/oauth2_token_type_hint.go b/pkg/coredata/oauth2_token_type_hint.go new file mode 100644 index 000000000..bdb016b2f --- /dev/null +++ b/pkg/coredata/oauth2_token_type_hint.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 OAuth2TokenTypeHint string + +const ( + OAuth2TokenTypeHintAccessToken OAuth2TokenTypeHint = "access_token" + OAuth2TokenTypeHintRefreshToken OAuth2TokenTypeHint = "refresh_token" +) + +func (h OAuth2TokenTypeHint) IsValid() bool { + switch h { + case OAuth2TokenTypeHintAccessToken, + OAuth2TokenTypeHintRefreshToken: + return true + } + + return false +} + +func (h OAuth2TokenTypeHint) String() string { return string(h) } + +func (h *OAuth2TokenTypeHint) UnmarshalText(text []byte) error { + *h = OAuth2TokenTypeHint(text) + if !h.IsValid() { + return fmt.Errorf("%s is not a valid OAuth2TokenTypeHint", string(text)) + } + + return nil +} + +func (h OAuth2TokenTypeHint) MarshalText() ([]byte, error) { + return []byte(h.String()), nil +} diff --git a/pkg/coredata/webhook_subscription.go b/pkg/coredata/webhook_subscription.go index c820c0d65..8dfdb113b 100644 --- a/pkg/coredata/webhook_subscription.go +++ b/pkg/coredata/webhook_subscription.go @@ -16,8 +16,6 @@ package coredata import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" "maps" @@ -26,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/crypto/cipher" + "go.probo.inc/probo/pkg/crypto/rand" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" ) @@ -45,12 +44,12 @@ type ( ) func (w *WebhookSubscription) GenerateSigningSecret(encryptionKey cipher.EncryptionKey) (string, error) { - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { + hexSecret, err := rand.HexString(32) + if err != nil { return "", fmt.Errorf("cannot generate signing secret: %w", err) } - signingSecret := "whsec_" + hex.EncodeToString(secret) + signingSecret := "whsec_" + hexSecret encrypted, err := cipher.Encrypt([]byte(signingSecret), encryptionKey) if err != nil { diff --git a/pkg/crypto/hash/hash.go b/pkg/crypto/hash/hash.go index 654ef69dd..71f0a0f1b 100644 --- a/pkg/crypto/hash/hash.go +++ b/pkg/crypto/hash/hash.go @@ -19,7 +19,19 @@ import ( "encoding/hex" ) -func SHA256Hex(data []byte) string { +func SHA256(data []byte) []byte { h := sha256.Sum256(data) - return hex.EncodeToString(h[:]) + return h[:] +} + +func SHA256String(s string) []byte { + return SHA256([]byte(s)) +} + +func SHA256Hex(data []byte) string { + return hex.EncodeToString(SHA256(data)) +} + +func SHA256HexString(s string) string { + return SHA256Hex([]byte(s)) } diff --git a/pkg/crypto/jose/jose.go b/pkg/crypto/jose/jose.go new file mode 100644 index 000000000..5f13716d1 --- /dev/null +++ b/pkg/crypto/jose/jose.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 jose + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" +) + +type ( + // JWK represents a JSON Web Key (RFC 7517). + JWK struct { + KeyType string `json:"kty"` + Use string `json:"use"` + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } + + // JWKS represents a JSON Web Key Set (RFC 7517). + JWKS struct { + Keys []JWK `json:"keys"` + } + + // JWTHeader represents a JWT header (RFC 7519). + JWTHeader struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` + KeyID string `json:"kid"` + } +) + +// RSAPublicKeyToJWK converts an RSA public key to a JWK with the given +// key ID, marked for RS256 signature use. +func RSAPublicKeyToJWK(pub *rsa.PublicKey, kid string) JWK { + return JWK{ + KeyType: "RSA", + Use: "sig", + Algorithm: "RS256", + KeyID: kid, + N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + } +} + +// SignJWT signs arbitrary claims as a JWT using RS256 with the given RSA +// private key and key ID. The claims value is JSON-marshaled as the payload. +func SignJWT(privateKey *rsa.PrivateKey, kid string, claims any) (string, error) { + header := JWTHeader{ + Algorithm: "RS256", + Type: "JWT", + KeyID: kid, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("cannot marshal jwt header: %w", err) + } + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("cannot marshal jwt claims: %w", err) + } + + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + signingInput := headerB64 + "." + claimsB64 + + h := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, h[:]) + if err != nil { + return "", fmt.Errorf("cannot sign jwt: %w", err) + } + + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + return signingInput + "." + signatureB64, nil +} diff --git a/pkg/crypto/jose/jose_test.go b/pkg/crypto/jose/jose_test.go new file mode 100644 index 000000000..3f3e8fccd --- /dev/null +++ b/pkg/crypto/jose/jose_test.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 jose_test + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/crypto/jose" +) + +func testRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + + key, err := rsa.GenerateKey( + strings.NewReader(strings.Repeat("deterministic-seed-for-test!!!!!", 100)), + 2048, + ) + require.NoError(t, err) + + return key +} + +func TestRSAPublicKeyToJWK(t *testing.T) { + t.Parallel() + + key := testRSAKey(t) + + t.Run( + "sets fixed RSA signature fields", + func(t *testing.T) { + t.Parallel() + + jwk := jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-1") + + assert.Equal(t, "RSA", jwk.KeyType) + assert.Equal(t, "sig", jwk.Use) + assert.Equal(t, "RS256", jwk.Algorithm) + assert.Equal(t, "kid-1", jwk.KeyID) + }, + ) + + t.Run( + "encodes modulus correctly", + func(t *testing.T) { + t.Parallel() + + jwk := jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-1") + + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + require.NoError(t, err) + + n := new(big.Int).SetBytes(nBytes) + assert.Equal(t, key.N, n) + }, + ) + + t.Run( + "encodes exponent correctly", + func(t *testing.T) { + t.Parallel() + + jwk := jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-1") + + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + require.NoError(t, err) + + e := new(big.Int).SetBytes(eBytes) + assert.Equal(t, int64(key.E), e.Int64()) + }, + ) + + t.Run( + "different key IDs produce different JWKs", + func(t *testing.T) { + t.Parallel() + + jwk1 := jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-a") + jwk2 := jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-b") + + assert.Equal(t, "kid-a", jwk1.KeyID) + assert.Equal(t, "kid-b", jwk2.KeyID) + assert.Equal(t, jwk1.N, jwk2.N) + }, + ) +} + +func TestSignJWT(t *testing.T) { + t.Parallel() + + key := testRSAKey(t) + + t.Run( + "produces valid three-part JWT", + func(t *testing.T) { + t.Parallel() + + claims := map[string]string{"sub": "test"} + + token, err := jose.SignJWT(key, "kid-1", claims) + require.NoError(t, err) + + parts := strings.Split(token, ".") + assert.Len(t, parts, 3) + }, + ) + + t.Run( + "header contains correct fields", + func(t *testing.T) { + t.Parallel() + + claims := map[string]string{"sub": "test"} + + token, err := jose.SignJWT(key, "my-kid", claims) + require.NoError(t, err) + + parts := strings.Split(token, ".") + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header jose.JWTHeader + err = json.Unmarshal(headerJSON, &header) + require.NoError(t, err) + + assert.Equal(t, "RS256", header.Algorithm) + assert.Equal(t, "JWT", header.Type) + assert.Equal(t, "my-kid", header.KeyID) + }, + ) + + t.Run( + "claims are correctly encoded", + func(t *testing.T) { + t.Parallel() + + claims := map[string]any{ + "iss": "https://issuer.example.com", + "sub": "sub-123", + "aud": "aud-456", + } + + token, err := jose.SignJWT(key, "kid-1", claims) + require.NoError(t, err) + + parts := strings.Split(token, ".") + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var decoded map[string]any + err = json.Unmarshal(claimsJSON, &decoded) + require.NoError(t, err) + + assert.Equal(t, "https://issuer.example.com", decoded["iss"]) + assert.Equal(t, "sub-123", decoded["sub"]) + assert.Equal(t, "aud-456", decoded["aud"]) + }, + ) + + t.Run( + "signature is verifiable", + func(t *testing.T) { + t.Parallel() + + claims := map[string]string{"sub": "test"} + + token, err := jose.SignJWT(key, "kid-1", claims) + require.NoError(t, err) + + parts := strings.Split(token, ".") + signingInput := parts[0] + "." + parts[1] + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + + h := sha256.Sum256([]byte(signingInput)) + err = rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, h[:], signature) + assert.NoError(t, err) + }, + ) +} + +func TestJWK_JSON(t *testing.T) { + t.Parallel() + + key := testRSAKey(t) + + t.Run( + "marshals to expected JSON field names", + func(t *testing.T) { + t.Parallel() + + jwk := jose.RSAPublicKeyToJWK(&key.PublicKey, "test-kid") + + data, err := json.Marshal(jwk) + require.NoError(t, err) + + var raw map[string]string + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + + assert.Equal(t, "RSA", raw["kty"]) + assert.Equal(t, "sig", raw["use"]) + assert.Equal(t, "RS256", raw["alg"]) + assert.Equal(t, "test-kid", raw["kid"]) + assert.NotEmpty(t, raw["n"]) + assert.NotEmpty(t, raw["e"]) + }, + ) +} + +func TestJWKS_JSON(t *testing.T) { + t.Parallel() + + key := testRSAKey(t) + + t.Run( + "marshals keys array", + func(t *testing.T) { + t.Parallel() + + jwks := jose.JWKS{ + Keys: []jose.JWK{ + jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-1"), + jose.RSAPublicKeyToJWK(&key.PublicKey, "kid-2"), + }, + } + + data, err := json.Marshal(jwks) + require.NoError(t, err) + + var raw struct { + Keys []json.RawMessage `json:"keys"` + } + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + + assert.Len(t, raw.Keys, 2) + }, + ) +} + +func TestJWTHeader_JSON(t *testing.T) { + t.Parallel() + + t.Run( + "marshals to expected JSON field names", + func(t *testing.T) { + t.Parallel() + + header := jose.JWTHeader{ + Algorithm: "RS256", + Type: "JWT", + KeyID: "my-kid", + } + + data, err := json.Marshal(header) + require.NoError(t, err) + + var raw map[string]string + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + + assert.Equal(t, "RS256", raw["alg"]) + assert.Equal(t, "JWT", raw["typ"]) + assert.Equal(t, "my-kid", raw["kid"]) + }, + ) +} diff --git a/pkg/crypto/rand/rand.go b/pkg/crypto/rand/rand.go new file mode 100644 index 000000000..6c62f69e6 --- /dev/null +++ b/pkg/crypto/rand/rand.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 rand + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "math/big" +) + +// HexString returns a hex-encoded cryptographically random string. +// The output is 2*byteLen characters long. +func HexString(byteLen int) (string, error) { + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("cannot generate random bytes: %w", err) + } + + return hex.EncodeToString(b), nil +} + +// MustHexString is like HexString but panics if the system entropy source is +// unavailable. +func MustHexString(byteLen int) string { + s, err := HexString(byteLen) + if err != nil { + panic("rand: crypto/rand is unavailable: " + err.Error()) + } + + return s +} + +// StringFromAlphabet returns a random string of length n, where each character +// is drawn uniformly from alphabet using crypto/rand. +func StringFromAlphabet(alphabet string, n int) (string, error) { + max := big.NewInt(int64(len(alphabet))) + buf := make([]byte, n) + + for i := range buf { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("cannot generate random bytes: %w", err) + } + + buf[i] = alphabet[idx.Int64()] + } + + return string(buf), nil +} + +// MustStringFromAlphabet is like StringFromAlphabet but panics if the system +// entropy source is unavailable. +func MustStringFromAlphabet(alphabet string, n int) string { + s, err := StringFromAlphabet(alphabet, n) + if err != nil { + panic("rand: crypto/rand is unavailable: " + err.Error()) + } + + return s +} diff --git a/pkg/gid/gid.go b/pkg/gid/gid.go index 137d1e96b..7aed4404a 100644 --- a/pkg/gid/gid.go +++ b/pkg/gid/gid.go @@ -47,6 +47,16 @@ func ParseGID(encoded string) (GID, error) { return gid, nil } +// MustParseGID parses a GID string and panics if it is invalid. +func MustParseGID(encoded string) GID { + id, err := ParseGID(encoded) + if err != nil { + panic(fmt.Sprintf("invalid GID: %s", encoded)) + } + + return id +} + // New creates a new GID with default entity type and nil tenant ID func New(tenantID TenantID, entityType uint16) GID { id, err := NewGID(tenantID, entityType) diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 92420eef0..d59d74c2f 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -16,7 +16,6 @@ package iam import ( "context" - "crypto/sha256" "errors" "fmt" "time" @@ -24,6 +23,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/crypto/hash" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/statelesstoken" @@ -715,6 +715,5 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s } func HashToken(token string) []byte { - hash := sha256.Sum256([]byte(token)) - return hash[:] + return hash.SHA256String(token) } diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index 8e12a022d..c92995037 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -131,6 +131,10 @@ func (a *Authorizer) authorize(ctx context.Context, tx pg.Tx, params AuthorizePa return fmt.Errorf("cannot build principal attributes: %w", err) } + if params.Session != nil { + principalAttrs["session_id"] = params.Session.String() + } + policies := a.buildPoliciesForRole(role) req := policy.AuthorizationRequest{ diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index 83a7d426a..b1f853f20 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -90,6 +90,10 @@ const ( ActionSCIMBridgeUpdate = "iam:scim-bridge:update" ActionSCIMBridgeDelete = "iam:scim-bridge:delete" + // OAuth2 Consent actions + ActionOAuth2ConsentGet = "iam:oauth2-consent:get" + ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve" + // Connector actions ActionConnectorGet = "iam:connector:get" diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go index a922323e1..0a4ab39d5 100644 --- a/pkg/iam/iam_policies.go +++ b/pkg/iam/iam_policies.go @@ -123,6 +123,23 @@ var IAMSelfManagePersonalAPIKeyPolicy = policy.NewPolicy( ). WithDescription("Allows users to manage their own personal API keys") +// IAMSelfManageOAuth2ConsentPolicy allows users to manage their own OAuth2 consents. +var IAMSelfManageOAuth2ConsentPolicy = policy.NewPolicy( + "iam:self-manage-oauth2-consent", + "Self-Manage OAuth2 Consents", + + policy.Allow( + ActionOAuth2ConsentGet, + ActionOAuth2ConsentApprove, + ). + WithSID("manage-own-consents"). + When( + policy.Equals("principal.id", "resource.identity_id"), + policy.Equals("principal.session_id", "resource.session_id"), + ), +). + WithDescription("Allows users to view and approve their own OAuth2 consents") + // IAMOwnerPolicy defines permissions for organization owners. var IAMOwnerPolicy = policy.NewPolicy( "iam:owner", diff --git a/pkg/iam/oauth2server/errors.go b/pkg/iam/oauth2server/errors.go new file mode 100644 index 000000000..25da8480a --- /dev/null +++ b/pkg/iam/oauth2server/errors.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "errors" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// OAuth2Error represents an OAuth2 protocol error with an associated +// error code per RFC 6749 §5.2 and RFC 8628 §3.5. +type OAuth2Error struct { + code string + description string +} + +func (e *OAuth2Error) Error() string { + if e.description != "" { + return e.code + ": " + e.description + } + return e.code +} + +func (e *OAuth2Error) ErrorCode() string { return e.code } +func (e *OAuth2Error) Description() string { return e.description } + +func (e *OAuth2Error) Is(target error) bool { + t, ok := target.(*OAuth2Error) + if !ok { + return false + } + return e.code == t.code +} + +type ErrorOption func(*OAuth2Error) + +func WithDescription(description string) ErrorOption { + return func(e *OAuth2Error) { + e.description = description + } +} + +func WithError(err error) ErrorOption { + return func(e *OAuth2Error) { + e.description = err.Error() + } +} + +// NewError creates a new OAuth2Error derived from a sentinel error code. +func NewError(code *OAuth2Error, opts ...ErrorOption) *OAuth2Error { + e := &OAuth2Error{code: code.code} + for _, opt := range opts { + opt(e) + } + return e +} + +var ( + // OAuth2 error codes per RFC 6749 §5.2 and RFC 8628 §3.5. + ErrInvalidRequest = &OAuth2Error{code: "invalid_request"} + ErrInvalidClient = &OAuth2Error{code: "invalid_client"} + ErrInvalidGrant = &OAuth2Error{code: "invalid_grant"} + ErrUnauthorizedClient = &OAuth2Error{code: "unauthorized_client"} + ErrUnsupportedGrantType = &OAuth2Error{code: "unsupported_grant_type"} + ErrInvalidScope = &OAuth2Error{code: "invalid_scope"} + ErrAccessDenied = &OAuth2Error{code: "access_denied"} + ErrServerError = &OAuth2Error{code: "server_error"} + ErrInvalidRedirectURI = &OAuth2Error{code: "invalid_redirect_uri"} + + // RFC 7009 revocation errors. + ErrUnsupportedTokenType = &OAuth2Error{code: "unsupported_token_type"} + + // RFC 8628 device flow errors. + ErrAuthorizationPending = &OAuth2Error{code: "authorization_pending"} + ErrSlowDown = &OAuth2Error{code: "slow_down"} + ErrExpiredToken = &OAuth2Error{code: "expired_token"} +) + +var ( + ErrClientNotFound = errors.New("client not found") + ErrConsentNotFound = errors.New("consent not found") + ErrDeviceCodeNotPending = errors.New("device code is not pending") + ErrUnauthorizedMember = errors.New("user is not a member of the client organization") +) + +// ConsentRequiredError is returned by Authorize when the user must approve +// the authorization request before a code can be issued. +type ConsentRequiredError struct { + ConsentID gid.GID + Client *coredata.OAuth2Client + Scopes coredata.OAuth2Scopes +} + +func (e *ConsentRequiredError) Error() string { + return "consent required" +} diff --git a/pkg/iam/oauth2server/gc.go b/pkg/iam/oauth2server/gc.go new file mode 100644 index 000000000..2182dc046 --- /dev/null +++ b/pkg/iam/oauth2server/gc.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" +) + +const ( + DefaultGCInterval = 5 * time.Minute +) + +type GarbageCollector = worker.Worker[struct{}] + +type gcHandler struct { + pg *pg.Client + logger *log.Logger + lastRunAt atomic.Int64 +} + +func NewGarbageCollector( + pgClient *pg.Client, + logger *log.Logger, + opts ...worker.Option, +) *GarbageCollector { + h := &gcHandler{ + pg: pgClient, + logger: logger.Named("oauth2server.garbage_collector"), + } + + return worker.New( + "oauth2server.garbage_collector", + h, + logger, + append( + []worker.Option{ + worker.WithInterval(DefaultGCInterval), + worker.WithMaxConcurrency(1), + }, + opts..., + )..., + ) +} + +func (h *gcHandler) Claim(_ context.Context) (struct{}, error) { + now := time.Now().UnixNano() + last := h.lastRunAt.Load() + + if last > 0 && now-last < int64(DefaultGCInterval) { + return struct{}{}, worker.ErrNoTask + } + + if !h.lastRunAt.CompareAndSwap(last, now) { + return struct{}{}, worker.ErrNoTask + } + + return struct{}{}, nil +} + +func (h *gcHandler) Process(ctx context.Context, _ struct{}) error { + return h.cleanup(ctx) +} + +func (h *gcHandler) cleanup(ctx context.Context) error { + now := time.Now() + + return h.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var authCode coredata.OAuth2AuthorizationCode + authCodesDeleted, err := authCode.DeleteExpired(ctx, tx, now) + if err != nil { + return fmt.Errorf("cannot delete expired authorization codes: %w", err) + } + + var accessToken coredata.OAuth2AccessToken + accessTokensDeleted, err := accessToken.DeleteExpired(ctx, tx, now) + if err != nil { + return fmt.Errorf("cannot delete expired access tokens: %w", err) + } + + var refreshToken coredata.OAuth2RefreshToken + refreshTokensDeleted, err := refreshToken.DeleteExpired(ctx, tx, now) + if err != nil { + return fmt.Errorf("cannot delete expired refresh tokens: %w", err) + } + + var deviceCode coredata.OAuth2DeviceCode + deviceCodesDeleted, err := deviceCode.DeleteExpired(ctx, tx, now) + if err != nil { + return fmt.Errorf("cannot delete expired device codes: %w", err) + } + + h.logger.InfoCtx( + ctx, + "oauth2 server garbage collector cleaned up", + log.Int64("authorization_codes_deleted", authCodesDeleted), + log.Int64("access_tokens_deleted", accessTokensDeleted), + log.Int64("refresh_tokens_deleted", refreshTokensDeleted), + log.Int64("device_codes_deleted", deviceCodesDeleted), + ) + + return nil + }, + ) +} diff --git a/pkg/iam/oauth2server/id_token.go b/pkg/iam/oauth2server/id_token.go new file mode 100644 index 000000000..6ccb36410 --- /dev/null +++ b/pkg/iam/oauth2server/id_token.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "time" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/uri" +) + +type ( + // SigningKey pairs an RSA private key with its key ID. All entries are + // published in the JWKS endpoint. Keys with Active set to true are + // used for signing new tokens; when multiple keys are active, the + // service round-robins between them. + SigningKey struct { + PrivateKey *rsa.PrivateKey + KID string + Active bool + } + + IDTokenClaims struct { + Issuer uri.URI `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + AuthTime int64 `json:"auth_time"` + Nonce string `json:"nonce,omitempty"` + AtHash string `json:"at_hash,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + Name string `json:"name,omitempty"` + Scope coredata.OAuth2Scopes `json:"-"` + } + + SigningKeys []SigningKey +) + +// ComputeAtHash computes the at_hash claim value for an access token. +// Per OIDC Core §3.1.3.6: left half of SHA-256 hash, base64url-encoded. +func ComputeAtHash(accessToken string) string { + h := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(h[:16]) +} + +func NewIDTokenClaims( + issuer uri.URI, + identityID gid.GID, + clientID gid.GID, + authTime time.Time, + scopes coredata.OAuth2Scopes, + nonce string, + accessToken string, + email string, + emailVerified bool, + fullName string, + ttl time.Duration, +) *IDTokenClaims { + now := time.Now() + + claims := &IDTokenClaims{ + Issuer: issuer, + Subject: identityID.String(), + Audience: clientID.String(), + ExpiresAt: now.Add(ttl).Unix(), + IssuedAt: now.Unix(), + AuthTime: authTime.Unix(), + Scope: scopes, + } + + if nonce != "" { + claims.Nonce = nonce + } + + if accessToken != "" { + claims.AtHash = ComputeAtHash(accessToken) + } + + for _, scope := range scopes { + switch scope { + case coredata.OAuth2ScopeEmail: + claims.Email = email + claims.EmailVerified = &emailVerified + case coredata.OAuth2ScopeProfile: + claims.Name = fullName + } + } + + return claims +} diff --git a/pkg/iam/oauth2server/id_token_test.go b/pkg/iam/oauth2server/id_token_test.go new file mode 100644 index 000000000..1b4710bbb --- /dev/null +++ b/pkg/iam/oauth2server/id_token_test.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server_test + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/uri" +) + +var testIssuer = uri.URI("https://issuer.example.com") + +func TestComputeAtHash(t *testing.T) { + t.Parallel() + + t.Run( + "returns left half of sha256 base64url encoded", + func(t *testing.T) { + t.Parallel() + + accessToken := "ya29.test-access-token" + h := sha256.Sum256([]byte(accessToken)) + expected := base64.RawURLEncoding.EncodeToString(h[:16]) + + result := oauth2server.ComputeAtHash(accessToken) + assert.Equal(t, expected, result) + }, + ) + + t.Run( + "different tokens produce different hashes", + func(t *testing.T) { + t.Parallel() + + hash1 := oauth2server.ComputeAtHash("token-a") + hash2 := oauth2server.ComputeAtHash("token-b") + assert.NotEqual(t, hash1, hash2) + }, + ) + + t.Run( + "empty token", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ComputeAtHash("") + assert.NotEmpty(t, result) + }, + ) + + t.Run( + "deterministic", + func(t *testing.T) { + t.Parallel() + + hash1 := oauth2server.ComputeAtHash("same-token") + hash2 := oauth2server.ComputeAtHash("same-token") + assert.Equal(t, hash1, hash2) + }, + ) +} + +func TestNewIDTokenClaims(t *testing.T) { + t.Parallel() + + identityID := gid.Nil + clientID := gid.Nil + authTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run( + "basic claims without optional scopes", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + "", + "", + "user@example.com", + true, + "John Doe", + 1*time.Hour, + ) + + assert.Equal(t, testIssuer, claims.Issuer) + assert.Equal(t, identityID.String(), claims.Subject) + assert.Equal(t, clientID.String(), claims.Audience) + assert.Equal(t, authTime.Unix(), claims.AuthTime) + assert.Empty(t, claims.Nonce) + assert.Empty(t, claims.AtHash) + assert.Empty(t, claims.Email) + assert.Nil(t, claims.EmailVerified) + assert.Empty(t, claims.Name) + }, + ) + + t.Run( + "sets nonce when provided", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + "test-nonce", + "", + "", + false, + "", + 1*time.Hour, + ) + + assert.Equal(t, "test-nonce", claims.Nonce) + }, + ) + + t.Run( + "computes at_hash when access token provided", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + "", + "access-token-123", + "", + false, + "", + 1*time.Hour, + ) + + expected := oauth2server.ComputeAtHash("access-token-123") + assert.Equal(t, expected, claims.AtHash) + }, + ) + + t.Run( + "includes email claims with email scope", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail}, + "", + "", + "user@example.com", + true, + "", + 1*time.Hour, + ) + + assert.Equal(t, "user@example.com", claims.Email) + require.NotNil(t, claims.EmailVerified) + assert.True(t, *claims.EmailVerified) + }, + ) + + t.Run( + "includes name with profile scope", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeProfile}, + "", + "", + "", + false, + "Jane Doe", + 1*time.Hour, + ) + + assert.Equal(t, "Jane Doe", claims.Name) + }, + ) + + t.Run( + "includes all claims with all scopes", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeEmail, + coredata.OAuth2ScopeProfile, + }, + "nonce-val", + "access-token", + "user@example.com", + false, + "John Doe", + 1*time.Hour, + ) + + assert.Equal(t, "nonce-val", claims.Nonce) + assert.NotEmpty(t, claims.AtHash) + assert.Equal(t, "user@example.com", claims.Email) + require.NotNil(t, claims.EmailVerified) + assert.False(t, *claims.EmailVerified) + assert.Equal(t, "John Doe", claims.Name) + }, + ) + + t.Run( + "sets expiration based on ttl", + func(t *testing.T) { + t.Parallel() + + ttl := 2 * time.Hour + before := time.Now() + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + "", + "", + "", + false, + "", + ttl, + ) + after := time.Now() + + assert.GreaterOrEqual(t, claims.ExpiresAt, before.Add(ttl).Unix()) + assert.LessOrEqual(t, claims.ExpiresAt, after.Add(ttl).Unix()) + assert.GreaterOrEqual(t, claims.IssuedAt, before.Unix()) + assert.LessOrEqual(t, claims.IssuedAt, after.Unix()) + }, + ) + + t.Run( + "email not verified", + func(t *testing.T) { + t.Parallel() + + claims := oauth2server.NewIDTokenClaims( + testIssuer, + identityID, + clientID, + authTime, + coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail}, + "", + "", + "user@example.com", + false, + "", + 1*time.Hour, + ) + + require.NotNil(t, claims.EmailVerified) + assert.False(t, *claims.EmailVerified) + }, + ) +} diff --git a/pkg/iam/oauth2server/metadata.go b/pkg/iam/oauth2server/metadata.go new file mode 100644 index 000000000..1e3e3e521 --- /dev/null +++ b/pkg/iam/oauth2server/metadata.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/uri" +) + +type ( + // ServerMetadata represents the OpenID Connect Discovery 1.0 / RFC 8414 + // authorization server metadata document. + ServerMetadata struct { + Issuer uri.URI `json:"issuer"` + AuthorizationEndpoint uri.URI `json:"authorization_endpoint"` + TokenEndpoint uri.URI `json:"token_endpoint"` + UserinfoEndpoint uri.URI `json:"userinfo_endpoint"` + JwksURI uri.URI `json:"jwks_uri"` + RegistrationEndpoint uri.URI `json:"registration_endpoint"` + IntrospectionEndpoint uri.URI `json:"introspection_endpoint"` + RevocationEndpoint uri.URI `json:"revocation_endpoint"` + DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"` + ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"` + ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"` + GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported"` + RevocationEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"revocation_endpoint_auth_methods_supported"` + IntrospectionEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"introspection_endpoint_auth_methods_supported"` + SubjectTypesSupported []coredata.OAuth2SubjectType `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []coredata.OAuth2SigningAlgorithm `json:"id_token_signing_alg_values_supported"` + CodeChallengeMethodsSupported []coredata.OAuth2CodeChallengeMethod `json:"code_challenge_methods_supported"` + ClaimsSupported []coredata.OAuth2Claim `json:"claims_supported"` + } + + // Endpoints holds the endpoint URLs for the OIDC discovery document. + Endpoints struct { + Authorization uri.URI + Token uri.URI + Userinfo uri.URI + JWKS uri.URI + Registration uri.URI + Introspection uri.URI + Revocation uri.URI + DeviceAuthorization uri.URI + } +) + +func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata { + return &ServerMetadata{ + Issuer: issuer, + AuthorizationEndpoint: endpoints.Authorization, + TokenEndpoint: endpoints.Token, + UserinfoEndpoint: endpoints.Userinfo, + JwksURI: endpoints.JWKS, + RegistrationEndpoint: endpoints.Registration, + IntrospectionEndpoint: endpoints.Introspection, + RevocationEndpoint: endpoints.Revocation, + DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization, + ScopesSupported: []coredata.OAuth2Scope{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeProfile, + coredata.OAuth2ScopeEmail, + coredata.OAuth2ScopeOfflineAccess, + }, + ResponseTypesSupported: []coredata.OAuth2ResponseType{ + coredata.OAuth2ResponseTypeCode, + }, + GrantTypesSupported: []coredata.OAuth2GrantType{ + coredata.OAuth2GrantTypeAuthorizationCode, + coredata.OAuth2GrantTypeRefreshToken, + coredata.OAuth2GrantTypeDeviceCode, + }, + TokenEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + RevocationEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + IntrospectionEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + SubjectTypesSupported: []coredata.OAuth2SubjectType{ + coredata.OAuth2SubjectTypePublic, + }, + IDTokenSigningAlgValuesSupported: []coredata.OAuth2SigningAlgorithm{ + coredata.OAuth2SigningAlgorithmRS256, + }, + CodeChallengeMethodsSupported: []coredata.OAuth2CodeChallengeMethod{ + coredata.OAuth2CodeChallengeMethodS256, + }, + ClaimsSupported: []coredata.OAuth2Claim{ + coredata.OAuth2ClaimIssuer, + coredata.OAuth2ClaimSubject, + coredata.OAuth2ClaimAudience, + coredata.OAuth2ClaimExpiration, + coredata.OAuth2ClaimIssuedAt, + coredata.OAuth2ClaimAuthTime, + coredata.OAuth2ClaimNonce, + coredata.OAuth2ClaimAtHash, + coredata.OAuth2ClaimEmail, + coredata.OAuth2ClaimEmailVerified, + coredata.OAuth2ClaimName, + }, + } +} diff --git a/pkg/iam/oauth2server/metadata_test.go b/pkg/iam/oauth2server/metadata_test.go new file mode 100644 index 000000000..1ec984500 --- /dev/null +++ b/pkg/iam/oauth2server/metadata_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/uri" +) + +func TestNewMetadata(t *testing.T) { + t.Parallel() + + issuer := uri.URI("https://auth.example.com") + endpoints := oauth2server.Endpoints{ + Authorization: "https://auth.example.com/authorize", + Token: "https://auth.example.com/token", + Userinfo: "https://auth.example.com/userinfo", + JWKS: "https://auth.example.com/.well-known/jwks.json", + Registration: "https://auth.example.com/register", + Introspection: "https://auth.example.com/introspect", + Revocation: "https://auth.example.com/revoke", + DeviceAuthorization: "https://auth.example.com/device", + } + + metadata := oauth2server.NewMetadata(issuer, endpoints) + require.NotNil(t, metadata) + + t.Run( + "issuer", + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, issuer, metadata.Issuer) + }, + ) + + t.Run( + "endpoints", + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, endpoints.Authorization, metadata.AuthorizationEndpoint) + assert.Equal(t, endpoints.Token, metadata.TokenEndpoint) + assert.Equal(t, endpoints.Userinfo, metadata.UserinfoEndpoint) + assert.Equal(t, endpoints.JWKS, metadata.JwksURI) + assert.Equal(t, endpoints.Registration, metadata.RegistrationEndpoint) + assert.Equal(t, endpoints.Introspection, metadata.IntrospectionEndpoint) + assert.Equal(t, endpoints.Revocation, metadata.RevocationEndpoint) + assert.Equal(t, endpoints.DeviceAuthorization, metadata.DeviceAuthorizationEndpoint) + }, + ) + + t.Run( + "scopes supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2Scope{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeProfile, + coredata.OAuth2ScopeEmail, + coredata.OAuth2ScopeOfflineAccess, + }, + metadata.ScopesSupported, + ) + }, + ) + + t.Run( + "response types supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2ResponseType{ + coredata.OAuth2ResponseTypeCode, + }, + metadata.ResponseTypesSupported, + ) + }, + ) + + t.Run( + "grant types supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2GrantType{ + coredata.OAuth2GrantTypeAuthorizationCode, + coredata.OAuth2GrantTypeRefreshToken, + coredata.OAuth2GrantTypeDeviceCode, + }, + metadata.GrantTypesSupported, + ) + }, + ) + + t.Run( + "token endpoint auth methods supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + metadata.TokenEndpointAuthMethodsSupported, + ) + }, + ) + + t.Run( + "revocation endpoint auth methods supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + metadata.RevocationEndpointAuthMethodsSupported, + ) + }, + ) + + t.Run( + "introspection endpoint auth methods supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2ClientTokenEndpointAuthMethod{ + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic, + coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost, + coredata.OAuth2ClientTokenEndpointAuthMethodNone, + }, + metadata.IntrospectionEndpointAuthMethodsSupported, + ) + }, + ) + + t.Run( + "subject types supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2SubjectType{ + coredata.OAuth2SubjectTypePublic, + }, + metadata.SubjectTypesSupported, + ) + }, + ) + + t.Run( + "id token signing algorithms supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2SigningAlgorithm{ + coredata.OAuth2SigningAlgorithmRS256, + }, + metadata.IDTokenSigningAlgValuesSupported, + ) + }, + ) + + t.Run( + "code challenge methods supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2CodeChallengeMethod{ + coredata.OAuth2CodeChallengeMethodS256, + }, + metadata.CodeChallengeMethodsSupported, + ) + }, + ) + + t.Run( + "claims supported", + func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + []coredata.OAuth2Claim{ + coredata.OAuth2ClaimIssuer, + coredata.OAuth2ClaimSubject, + coredata.OAuth2ClaimAudience, + coredata.OAuth2ClaimExpiration, + coredata.OAuth2ClaimIssuedAt, + coredata.OAuth2ClaimAuthTime, + coredata.OAuth2ClaimNonce, + coredata.OAuth2ClaimAtHash, + coredata.OAuth2ClaimEmail, + coredata.OAuth2ClaimEmailVerified, + coredata.OAuth2ClaimName, + }, + metadata.ClaimsSupported, + ) + }, + ) +} diff --git a/pkg/iam/oauth2server/pkce.go b/pkg/iam/oauth2server/pkce.go new file mode 100644 index 000000000..922f7c8a2 --- /dev/null +++ b/pkg/iam/oauth2server/pkce.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + + "go.probo.inc/probo/pkg/coredata" +) + +func ValidateCodeChallenge(verifier, challenge string, method coredata.OAuth2CodeChallengeMethod) bool { + if verifier == "" || challenge == "" { + return false + } + + switch method { + case coredata.OAuth2CodeChallengeMethodS256: + return validateS256(verifier, challenge) + default: + return false + } +} + +func validateS256(verifier, challenge string) bool { + h := sha256.Sum256([]byte(verifier)) + computed := base64.RawURLEncoding.EncodeToString(h[:]) + + return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1 +} diff --git a/pkg/iam/oauth2server/pkce_test.go b/pkg/iam/oauth2server/pkce_test.go new file mode 100644 index 000000000..60b1f647e --- /dev/null +++ b/pkg/iam/oauth2server/pkce_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server_test + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2server" +) + +func computeS256Challenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func TestValidateCodeChallenge(t *testing.T) { + t.Parallel() + + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := computeS256Challenge(verifier) + + t.Run( + "valid s256", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + verifier, + challenge, + coredata.OAuth2CodeChallengeMethodS256, + ) + + require.True(t, result) + }, + ) + + t.Run( + "wrong verifier", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + "wrong-verifier", + challenge, + coredata.OAuth2CodeChallengeMethodS256, + ) + + assert.False(t, result) + }, + ) + + t.Run( + "wrong challenge", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + verifier, + "wrong-challenge", + coredata.OAuth2CodeChallengeMethodS256, + ) + + assert.False(t, result) + }, + ) + + t.Run( + "unsupported method", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + verifier, + challenge, + coredata.OAuth2CodeChallengeMethod("plain"), + ) + + assert.False(t, result) + }, + ) + + t.Run( + "empty method", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + verifier, + challenge, + coredata.OAuth2CodeChallengeMethod(""), + ) + + assert.False(t, result) + }, + ) + + t.Run( + "empty verifier", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + "", + challenge, + coredata.OAuth2CodeChallengeMethodS256, + ) + + assert.False(t, result) + }, + ) + + t.Run( + "empty challenge", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + verifier, + "", + coredata.OAuth2CodeChallengeMethodS256, + ) + + assert.False(t, result) + }, + ) + + t.Run( + "both empty", + func(t *testing.T) { + t.Parallel() + + result := oauth2server.ValidateCodeChallenge( + "", + "", + coredata.OAuth2CodeChallengeMethodS256, + ) + + assert.False(t, result) + }, + ) +} diff --git a/pkg/iam/oauth2server/service.go b/pkg/iam/oauth2server/service.go new file mode 100644 index 000000000..58b6627fe --- /dev/null +++ b/pkg/iam/oauth2server/service.go @@ -0,0 +1,1675 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2server + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net/url" + "sync/atomic" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/crypto/hash" + "go.probo.inc/probo/pkg/crypto/jose" + "go.probo.inc/probo/pkg/crypto/rand" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/net" + "go.probo.inc/probo/pkg/uri" +) + +// CLIClientID is the well-known OAuth2 client ID for the Probo CLI. +// It is inserted into every Probo database via migration and hardcoded +// in the CLI binary for the device authorization flow. +var CLIClientID = gid.MustParseGID("AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp") + +const ( + tokenByteLength = 32 + refreshTokenByteLength = 48 + tokenTypeBearer = "Bearer" + + // userCodeAlphabet excludes ambiguous characters: 0/O, 1/I/L. + userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +) + +type ( + Service struct { + pg *pg.Client + signingKeys SigningKeys + activeSigningIdx []int + rrCounter atomic.Uint64 + baseURL uri.URI + logger *log.Logger + gc *GarbageCollector + accessTokenDuration time.Duration + refreshTokenDuration time.Duration + authorizationCodeDuration time.Duration + deviceCodeDuration time.Duration + } + + Option func(*Service) + + AuthorizeRequest struct { + IdentityID gid.GID + SessionID gid.GID + ResponseType coredata.OAuth2ResponseType + ClientID gid.GID + RedirectURI string + Scopes coredata.OAuth2Scopes + CodeChallenge string + CodeChallengeMethod coredata.OAuth2CodeChallengeMethod + Nonce string + State string + AuthTime time.Time + } + + ConsentApprovalRequest struct { + ConsentID gid.GID + IdentityID gid.GID + SessionID gid.GID + Approved bool + AuthTime time.Time + } + + RegisterClientRequest struct { + IdentityID gid.GID + OrganizationID *gid.GID + ClientName string + Visibility coredata.OAuth2ClientVisibility + RedirectURIs []uri.URI + GrantTypes []coredata.OAuth2GrantType + ResponseTypes []coredata.OAuth2ResponseType + TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod + LogoURI *uri.URI + ClientURI *uri.URI + Contacts []string + Scopes coredata.OAuth2Scopes + } + + TokenResult struct { + AccessToken string + TokenType string + ExpiresIn int64 + RefreshToken string + IDToken string + Scope string + } +) + +func WithAccessTokenDuration(d time.Duration) Option { + return func(s *Service) { + s.accessTokenDuration = d + } +} + +func WithRefreshTokenDuration(d time.Duration) Option { + return func(s *Service) { + s.refreshTokenDuration = d + } +} + +func WithAuthorizationCodeDuration(d time.Duration) Option { + return func(s *Service) { + s.authorizationCodeDuration = d + } +} + +func WithDeviceCodeDuration(d time.Duration) Option { + return func(s *Service) { + s.deviceCodeDuration = d + } +} + +func NewService( + pgClient *pg.Client, + signingKeys SigningKeys, + baseURL uri.URI, + logger *log.Logger, + opts ...Option, +) *Service { + var activeIdx []int + for i, k := range signingKeys { + if k.Active { + activeIdx = append(activeIdx, i) + } + } + + s := &Service{ + pg: pgClient, + signingKeys: signingKeys, + activeSigningIdx: activeIdx, + baseURL: baseURL, + logger: logger, + accessTokenDuration: 1 * time.Hour, + refreshTokenDuration: 30 * 24 * time.Hour, + authorizationCodeDuration: 10 * time.Minute, + deviceCodeDuration: 10 * time.Minute, + } + + for _, opt := range opts { + opt(s) + } + + s.gc = NewGarbageCollector(pgClient, logger) + + return s +} + +// signingKey returns the next active signing key using round-robin. +func (s *Service) signingKey() *SigningKey { + n := s.rrCounter.Add(1) + idx := s.activeSigningIdx[n%uint64(len(s.activeSigningIdx))] + return &s.signingKeys[idx] +} + +func (s *Service) Run(ctx context.Context) error { + return s.gc.Run(ctx) +} + +// Metadata returns the OIDC discovery document. +func (s *Service) Metadata(endpoints Endpoints) *ServerMetadata { + return NewMetadata(s.baseURL, endpoints) +} + +// JWKS returns the public key set. +func (s *Service) JWKS() *jose.JWKS { + jwks := &jose.JWKS{ + Keys: make([]jose.JWK, 0, len(s.signingKeys)), + } + + for _, sk := range s.signingKeys { + jwks.Keys = append( + jwks.Keys, + jose.RSAPublicKeyToJWK(&sk.PrivateKey.PublicKey, sk.KID), + ) + } + + return jwks +} + +func (s *Service) CreateAccessToken( + ctx context.Context, + clientID gid.GID, + identityID gid.GID, + scopes coredata.OAuth2Scopes, +) (string, *coredata.OAuth2AccessToken, error) { + tokenValue := rand.MustHexString(tokenByteLength) + + now := time.Now() + token := &coredata.OAuth2AccessToken{ + ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType), + HashedValue: hash.SHA256String(tokenValue), + ClientID: clientID, + IdentityID: identityID, + Scopes: scopes, + CreatedAt: now, + ExpiresAt: now.Add(s.accessTokenDuration), + } + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := token.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create access token: %w", err) + } + + return nil + }, + ); err != nil { + return "", nil, err + } + + return tokenValue, token, nil +} + +func (s *Service) GetClientByID(ctx context.Context, clientID gid.GID) (*coredata.OAuth2Client, error) { + client := coredata.OAuth2Client{} + + if err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := client.LoadByID(ctx, conn, coredata.NewNoScope(), clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError(ErrInvalidClient, WithDescription("client not found")) + } + + return fmt.Errorf("cannot load oauth2 client: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return &client, nil +} + +func (s *Service) ExchangeAuthorizationCode( + ctx context.Context, + client *coredata.OAuth2Client, + codeValue, redirectURI, codeVerifier string, +) (*TokenResult, error) { + var ( + code = coredata.OAuth2AuthorizationCode{} + identity = coredata.Identity{} + now = time.Now() + accessTokenExpiresAt = now.Add(s.accessTokenDuration) + accessTokenValue = rand.MustHexString(tokenByteLength) + accessTokenID = gid.New(client.ID.TenantID(), coredata.OAuth2AccessTokenEntityType) + refreshTokenValue string + idToken string + ) + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := code.LoadByHashForUpdate(ctx, tx, hash.SHA256String(codeValue), client.ID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError(ErrInvalidGrant, WithDescription("authorization code not found")) + } + + return fmt.Errorf("cannot load authorization code: %w", err) + } + + // RFC 6819 §5.2.1.1: if the code was already redeemed, this is + // a replay attack. Revoke all tokens derived from this code. + if code.RedeemedAt != nil { + s.logger.WarnCtx( + ctx, + "authorization code replay detected, revoking derived tokens", + log.String("client_id", client.ID.String()), + log.String("identity_id", code.IdentityID.String()), + ) + + if code.AccessTokenID != nil { + derivedAccessToken := coredata.OAuth2AccessToken{ID: *code.AccessTokenID} + if err := derivedAccessToken.Delete(ctx, tx); err != nil { + s.logger.ErrorCtx( + ctx, + "cannot delete derived access token", + log.String("access_token_id", code.AccessTokenID.String()), + log.Error(err), + ) + } + + derivedRefreshToken := &coredata.OAuth2RefreshToken{} + if _, err := derivedRefreshToken.RevokeByAccessTokenID(ctx, tx, *code.AccessTokenID, now); err != nil { + s.logger.ErrorCtx( + ctx, + "cannot revoke derived refresh tokens", + log.String("access_token_id", code.AccessTokenID.String()), + log.Error(err), + ) + } + } + + return pg.NoRollback( + NewError( + ErrInvalidGrant, + WithDescription("authorization code already redeemed"), + ), + ) + } + + if err := identity.LoadByID(ctx, tx, code.IdentityID); err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + + if err := code.Redeem(ctx, tx, now, accessTokenID); err != nil { + return fmt.Errorf("cannot redeem authorization code: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + if now.After(code.ExpiresAt) { + return nil, NewError( + ErrInvalidGrant, + WithDescription("authorization code expired"), + ) + } + + if code.RedirectURI.String() != redirectURI { + return nil, NewError( + ErrInvalidRedirectURI, + WithDescription("redirect_uri mismatch"), + ) + } + + if code.CodeChallenge != nil { + if codeVerifier == "" { + return nil, NewError( + ErrInvalidRequest, + WithDescription("code_verifier required"), + ) + } + + if !ValidateCodeChallenge(codeVerifier, *code.CodeChallenge, *code.CodeChallengeMethod) { + return nil, NewError( + ErrInvalidRequest, + WithDescription("invalid code_verifier"), + ) + } + } + + if code.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + var ( + idTokenClaims = NewIDTokenClaims( + s.baseURL, + code.IdentityID, + client.ID, + code.AuthTime, + code.Scopes, + ref.UnrefOrZero(code.Nonce), + accessTokenValue, + identity.EmailAddress.String(), + identity.EmailAddressVerified, + identity.FullName, + s.accessTokenDuration, + ) + sk = s.signingKey() + err error + ) + + idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, idTokenClaims) + if err != nil { + return nil, fmt.Errorf("cannot sign id token: %w", err) + } + } + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + accessToken := &coredata.OAuth2AccessToken{ + ID: accessTokenID, + HashedValue: hash.SHA256String(accessTokenValue), + ClientID: client.ID, + IdentityID: code.IdentityID, + Scopes: code.Scopes, + CreatedAt: now, + ExpiresAt: accessTokenExpiresAt, + } + + if err := accessToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create access token: %w", err) + } + + if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) { + refreshTokenValue = rand.MustHexString(refreshTokenByteLength) + + refreshToken := &coredata.OAuth2RefreshToken{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2RefreshTokenEntityType), + HashedValue: hash.SHA256String(refreshTokenValue), + ClientID: client.ID, + IdentityID: code.IdentityID, + Scopes: code.Scopes, + AccessTokenID: accessToken.ID, + CreatedAt: now, + ExpiresAt: now.Add(s.refreshTokenDuration), + } + + if err := refreshToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create refresh token: %w", err) + } + } + + return nil + }, + ); err != nil { + return nil, err + } + + return &TokenResult{ + AccessToken: accessTokenValue, + TokenType: tokenTypeBearer, + ExpiresIn: int64(time.Until(accessTokenExpiresAt).Seconds()), + RefreshToken: refreshTokenValue, + Scope: code.Scopes.String(), + IDToken: idToken, + }, nil +} + +func (s *Service) RefreshToken( + ctx context.Context, + client *coredata.OAuth2Client, + refreshTokenValue string, +) (*TokenResult, error) { + var ( + accessTokenValue = rand.MustHexString(tokenByteLength) + refreshTokenValueNew = rand.MustHexString(refreshTokenByteLength) + hashedValue = hash.SHA256String(refreshTokenValue) + now = time.Now() + accessTokenExpiresAt = now.Add(s.accessTokenDuration) + idToken string + previousRefreshToken = coredata.OAuth2RefreshToken{} + identity = coredata.Identity{} + ) + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := previousRefreshToken.LoadByHashedValueForUpdate( + ctx, + tx, + hashedValue, + client.ID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError( + ErrInvalidGrant, + WithDescription("refresh token not found"), + ) + } + + return fmt.Errorf("cannot load refresh token: %w", err) + } + + if err := identity.LoadByID(ctx, tx, previousRefreshToken.IdentityID); err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + + if previousRefreshToken.RevokedAt != nil { + s.logger.WarnCtx( + ctx, + "refresh token replay detected, revoking all tokens", + log.String("client_id", client.ID.String()), + log.String("identity_id", previousRefreshToken.IdentityID.String()), + ) + + accessToken := &coredata.OAuth2AccessToken{} + if _, err := accessToken.DeleteByClientAndIdentity( + ctx, + tx, + client.ID, + previousRefreshToken.IdentityID, + ); err != nil { + s.logger.ErrorCtx( + ctx, + "cannot delete access tokens", + log.String("access_token_id", previousRefreshToken.AccessTokenID.String()), + log.Error(err), + ) + } + + refreshToken := &coredata.OAuth2RefreshToken{} + if _, err := refreshToken.RevokeByClientAndIdentity( + ctx, + tx, + client.ID, + previousRefreshToken.IdentityID, + now, + ); err != nil { + s.logger.ErrorCtx( + ctx, + "cannot revoke refresh tokens", + log.String("refresh_token_id", previousRefreshToken.ID.String()), + log.Error(err), + ) + } + + return pg.NoRollback( + NewError( + ErrInvalidGrant, + WithDescription("refresh token replay detected"), + ), + ) + } + + return nil + }, + ); err != nil { + return nil, err + } + + if now.After(previousRefreshToken.ExpiresAt) { + return nil, NewError( + ErrInvalidGrant, + WithDescription("refresh token expired"), + ) + } + + if previousRefreshToken.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + var ( + claims = NewIDTokenClaims( + s.baseURL, + previousRefreshToken.IdentityID, + client.ID, + time.Now(), + previousRefreshToken.Scopes, + "", + accessTokenValue, + identity.EmailAddress.String(), + identity.EmailAddressVerified, + identity.FullName, + s.accessTokenDuration, + ) + sk = s.signingKey() + err error + ) + + idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, claims) + if err != nil { + return nil, fmt.Errorf("cannot sign id token: %w", err) + } + } + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := previousRefreshToken.Revoke(ctx, tx, now); err != nil { + return fmt.Errorf("cannot revoke previous refresh token: %w", err) + } + + // Attempt to delete the previous (legacy) access token. + // If this fails, ignore the error; access tokens are short-lived and already + // unlinked from refresh tokens. + legacyAccessToken := coredata.OAuth2AccessToken{ID: previousRefreshToken.AccessTokenID} + if err := legacyAccessToken.Delete(ctx, tx); err != nil { + s.logger.ErrorCtx( + ctx, + "cannot delete legacy access token", + log.String("access_token_id", previousRefreshToken.AccessTokenID.String()), + log.Error(err), + ) + } + + accessToken := &coredata.OAuth2AccessToken{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2AccessTokenEntityType), + HashedValue: hash.SHA256String(accessTokenValue), + ClientID: client.ID, + IdentityID: previousRefreshToken.IdentityID, + Scopes: previousRefreshToken.Scopes, + CreatedAt: now, + ExpiresAt: accessTokenExpiresAt, + } + if err := accessToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create access token: %w", err) + } + + refreshToken := &coredata.OAuth2RefreshToken{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2RefreshTokenEntityType), + HashedValue: hash.SHA256String(refreshTokenValueNew), + ClientID: client.ID, + IdentityID: previousRefreshToken.IdentityID, + Scopes: previousRefreshToken.Scopes, + AccessTokenID: accessToken.ID, + CreatedAt: now, + ExpiresAt: now.Add(s.refreshTokenDuration), + } + if err := refreshToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create refresh token: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return &TokenResult{ + AccessToken: accessTokenValue, + TokenType: tokenTypeBearer, + ExpiresIn: int64(time.Until(accessTokenExpiresAt).Seconds()), + RefreshToken: refreshTokenValueNew, + Scope: previousRefreshToken.Scopes.String(), + IDToken: idToken, + }, nil +} + +func (s *Service) CreateDeviceCode( + ctx context.Context, + clientID gid.GID, + scopes coredata.OAuth2Scopes, +) (string, *coredata.OAuth2DeviceCode, error) { + var ( + deviceCodeValue = rand.MustHexString(tokenByteLength) + deviceCode *coredata.OAuth2DeviceCode + now = time.Now() + ) + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + client := coredata.OAuth2Client{} + if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError( + ErrInvalidRequest, + WithDescription("unknown client_id"), + ) + } + + return fmt.Errorf("cannot load oauth2 client: %w", err) + } + + if !client.HasGrantType(coredata.OAuth2GrantTypeDeviceCode) { + return NewError( + ErrUnauthorizedClient, + WithDescription("client not authorized for device flow"), + ) + } + + requestedScopes := scopes.OrDefault(client.Scopes) + if !client.AreScopesAllowed(requestedScopes) { + return NewError( + ErrInvalidScope, + WithDescription("requested scope exceeds client registration"), + ) + } + + if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { + return NewError( + ErrInvalidScope, + WithDescription("offline_access requires the refresh_token grant type"), + ) + } + + // Try up to 3 times to generate a unique user code, retrying if we detect a collision on insertion. + // This minimizes the (rare) chance of user code collisions due to the limited keyspace. + for range 3 { + userCode := rand.MustStringFromAlphabet(userCodeAlphabet, 8) + + candidate := &coredata.OAuth2DeviceCode{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2DeviceCodeEntityType), + DeviceCodeHash: hash.SHA256String(deviceCodeValue), + UserCode: coredata.OAuth2UserCode(userCode), + ClientID: client.ID, + Scopes: requestedScopes, + Status: coredata.OAuth2DeviceCodeStatusPending, + PollInterval: 5, + CreatedAt: now, + ExpiresAt: now.Add(s.deviceCodeDuration), + } + + if err := candidate.Insert(ctx, tx); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + continue + } + + return fmt.Errorf("cannot insert device code: %w", err) + } + + deviceCode = candidate + + return nil + } + + return fmt.Errorf("cannot generate unique user code after 3 attempts") + }, + ); err != nil { + return "", nil, err + } + + return deviceCodeValue, deviceCode, nil +} + +func (s *Service) PollDeviceCode( + ctx context.Context, + clientID gid.GID, + deviceCodeValue string, +) (*TokenResult, error) { + var ( + identity = coredata.Identity{} + hashedValue = hash.SHA256String(deviceCodeValue) + deviceCode = coredata.OAuth2DeviceCode{} + now = time.Now() + client = &coredata.OAuth2Client{} + ) + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := deviceCode.LoadByDeviceCodeHashForUpdate(ctx, tx, hashedValue, clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError( + ErrInvalidGrant, + WithDescription("invalid device code"), + ) + } + + return fmt.Errorf("cannot load device code: %w", err) + } + + if deviceCode.IdentityID != nil { + if err := identity.LoadByID(ctx, tx, *deviceCode.IdentityID); err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + } + + if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), clientID); err != nil { + return fmt.Errorf("cannot load client: %w", err) + } + + // Rate limiting. + var slowDown bool + if deviceCode.LastPolledAt != nil { + elapsed := now.Sub(ref.UnrefOrZero(deviceCode.LastPolledAt)) + if elapsed < time.Duration(deviceCode.PollInterval)*time.Second { + deviceCode.PollInterval += 5 + slowDown = true + } + } + + deviceCode.LastPolledAt = &now + + if err := deviceCode.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot update device code: %w", err) + } + + if slowDown { + return NewError( + ErrSlowDown, + WithDescription("slow down"), + ) + } + + // Ensure code is deleted whehever what is happening next the code must not be used again. + if deviceCode.Status == coredata.OAuth2DeviceCodeStatusAuthorized { + if err := deviceCode.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete device code: %w", err) + } + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + if now.After(deviceCode.ExpiresAt) { + return nil, NewError( + ErrExpiredToken, + WithDescription("expired token"), + ) + } + + switch deviceCode.Status { + case coredata.OAuth2DeviceCodeStatusPending: + return nil, NewError( + ErrAuthorizationPending, + WithDescription("authorization pending"), + ) + case coredata.OAuth2DeviceCodeStatusDenied: + return nil, NewError( + ErrAccessDenied, + WithDescription("access denied"), + ) + case coredata.OAuth2DeviceCodeStatusAuthorized: + // Continue to issue tokens. + case coredata.OAuth2DeviceCodeStatusExpired: + return nil, NewError( + ErrExpiredToken, + WithDescription("expired token"), + ) + default: + return nil, fmt.Errorf("invalid device code status: %q", deviceCode.Status) + } + + var ( + accessTokenValue = rand.MustHexString(tokenByteLength) + refreshTokenValue string + accessTokenExpiresAt = now.Add(s.accessTokenDuration) + idToken string + ) + + if deviceCode.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + var ( + claims = NewIDTokenClaims( + s.baseURL, + *deviceCode.IdentityID, + clientID, + now, + deviceCode.Scopes, + "", + accessTokenValue, + identity.EmailAddress.String(), + identity.EmailAddressVerified, + identity.FullName, + s.accessTokenDuration, + ) + sk = s.signingKey() + err error + ) + + idToken, err = jose.SignJWT(sk.PrivateKey, sk.KID, claims) + if err != nil { + return nil, fmt.Errorf("cannot sign id token: %w", err) + } + } + + if err = s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + accessToken := &coredata.OAuth2AccessToken{ + ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType), + HashedValue: hash.SHA256String(accessTokenValue), + ClientID: clientID, + IdentityID: *deviceCode.IdentityID, + Scopes: deviceCode.Scopes, + CreatedAt: now, + ExpiresAt: accessTokenExpiresAt, + } + if err := accessToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create access token: %w", err) + } + + if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) { + refreshTokenValue = rand.MustHexString(refreshTokenByteLength) + + refreshToken := &coredata.OAuth2RefreshToken{ + ID: gid.New(clientID.TenantID(), coredata.OAuth2RefreshTokenEntityType), + HashedValue: hash.SHA256String(refreshTokenValue), + ClientID: clientID, + IdentityID: *deviceCode.IdentityID, + Scopes: deviceCode.Scopes, + AccessTokenID: accessToken.ID, + CreatedAt: now, + ExpiresAt: now.Add(s.refreshTokenDuration), + } + + if err := refreshToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create refresh token: %w", err) + } + } + + return nil + }, + ); err != nil { + return nil, err + } + + return &TokenResult{ + AccessToken: accessTokenValue, + TokenType: tokenTypeBearer, + ExpiresIn: int64(accessTokenExpiresAt.Sub(now).Seconds()), + RefreshToken: refreshTokenValue, + Scope: deviceCode.Scopes.String(), + IDToken: idToken, + }, nil +} + +func (s *Service) AuthorizeDevice( + ctx context.Context, + identityID gid.GID, + sessionID gid.GID, + userCode string, +) error { + var ( + deviceCode coredata.OAuth2DeviceCode + client coredata.OAuth2Client + ) + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := deviceCode.LoadByUserCodeForUpdate(ctx, tx, userCode); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError( + ErrInvalidGrant, + WithDescription("invalid user code"), + ) + } + + return fmt.Errorf("cannot load device code: %w", err) + } + + if time.Now().After(deviceCode.ExpiresAt) { + return NewError( + ErrExpiredToken, + WithDescription("expired token"), + ) + } + + if deviceCode.Status != coredata.OAuth2DeviceCodeStatusPending { + return NewError( + ErrInvalidGrant, + WithDescription(fmt.Sprintf("device code already %s", deviceCode.Status)), + ) + } + + if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), deviceCode.ClientID); err != nil { + return fmt.Errorf("cannot load oauth2 client: %w", err) + } + + // RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always + // require explicit user consent since they cannot be strongly + // authenticated. + if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone { + var existingConsent coredata.OAuth2Consent + if err := existingConsent.LoadMatchingConsent( + ctx, + tx, + identityID, + client.ID, + deviceCode.Scopes, + ); err == nil { + deviceCode.Status = coredata.OAuth2DeviceCodeStatusAuthorized + deviceCode.IdentityID = &identityID + + if err := deviceCode.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot update device code: %w", err) + } + + return nil + } + } + + now := time.Now() + pendingConsent := &coredata.OAuth2Consent{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2ConsentEntityType), + IdentityID: identityID, + SessionID: sessionID, + ClientID: client.ID, + Scopes: deviceCode.Scopes, + DeviceCodeID: &deviceCode.ID, + Approved: false, + CreatedAt: now, + UpdatedAt: now, + } + + if err := pendingConsent.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert pending consent: %w", err) + } + + return pg.NoRollback( + &ConsentRequiredError{ + ConsentID: pendingConsent.ID, + Client: &client, + Scopes: deviceCode.Scopes, + }, + ) + }, + ) +} + +func (s *Service) RegisterClient( + ctx context.Context, + req *RegisterClientRequest, +) (gid.GID, string, error) { + for _, u := range req.RedirectURIs { + parsed, _ := url.Parse(u.String()) + + switch req.Visibility { + case coredata.OAuth2ClientVisibilityPublic: + if parsed.Scheme != "https" { + return gid.Nil, + "", + NewError( + ErrInvalidRequest, + WithDescription("public clients require https redirect_uris"), + ) + } + case coredata.OAuth2ClientVisibilityPrivate: + if parsed.Scheme == "http" { + if !net.IsLoopback(parsed.Hostname()) { + return gid.Nil, + "", + NewError( + ErrInvalidRequest, + WithDescription("http redirect_uris are only allowed for localhost"), + ) + } + } else if parsed.Scheme != "https" { + return gid.Nil, + "", + NewError( + ErrInvalidRequest, + WithDescription(fmt.Sprintf("unsupported redirect_uri scheme: %s", parsed.Scheme)), + ) + } + } + } + + var ( + plaintextSecret string + secretHash []byte + ) + + if req.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone { + plaintextSecret = rand.MustHexString(tokenByteLength) + secretHash = hash.SHA256String(plaintextSecret) + } + + if req.OrganizationID == nil { + return gid.Nil, "", NewError( + ErrInvalidRequest, + WithDescription("organization_id is required"), + ) + } + + var ( + now = time.Now() + scope = coredata.NewScopeFromObjectID(*req.OrganizationID) + client = &coredata.OAuth2Client{ + ID: gid.New(scope.GetTenantID(), coredata.OAuth2ClientEntityType), + OrganizationID: req.OrganizationID, + ClientSecretHash: secretHash, + ClientName: req.ClientName, + Visibility: req.Visibility, + RedirectURIs: req.RedirectURIs, + Scopes: req.Scopes, + GrantTypes: req.GrantTypes, + ResponseTypes: req.ResponseTypes, + TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, + LogoURI: req.LogoURI, + ClientURI: req.ClientURI, + Contacts: req.Contacts, + CreatedAt: now, + UpdatedAt: now, + } + ) + + var membership coredata.Membership + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := membership.LoadActiveByIdentityIDAndOrganizationID( + ctx, + tx, + req.IdentityID, + *req.OrganizationID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError( + ErrAccessDenied, + WithDescription("not a member of the organization"), + ) + } + + return fmt.Errorf("cannot load membership: %w", err) + } + + if err := client.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert oauth2 client: %w", err) + } + + return nil + }, + ) + if err != nil { + return gid.Nil, "", err + } + + return client.ID, plaintextSecret, nil +} + +func (s *Service) LoadAccessToken(ctx context.Context, tokenValue string) (*coredata.OAuth2AccessToken, error) { + var ( + hashedValue = hash.SHA256String(tokenValue) + token coredata.OAuth2AccessToken + now = time.Now() + ) + + if err := s.pg.WithConn( + ctx, + func(ctx context.Context, tx pg.Querier) error { + if err := token.LoadByHashedValue(ctx, tx, hashedValue); err != nil { + return fmt.Errorf("cannot load access token: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + if now.After(token.ExpiresAt) { + return nil, fmt.Errorf("access token expired") + } + + return &token, nil +} + +func (s *Service) IntrospectToken(ctx context.Context, clientID gid.GID, tokenValue string) (*coredata.OAuth2AccessToken, error) { + var ( + hashedValue = hash.SHA256String(tokenValue) + token = coredata.OAuth2AccessToken{} + now = time.Now() + ) + + if err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := token.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil + } + + return fmt.Errorf("cannot load access token: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + if token.ID == gid.Nil || now.After(token.ExpiresAt) { + return nil, nil + } + + return &token, nil +} + +func (s *Service) UserInfo( + ctx context.Context, + identityID gid.GID, + scopes coredata.OAuth2Scopes, +) (map[string]any, error) { + identity := &coredata.Identity{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := identity.LoadByID(ctx, conn, identityID); err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + claims := map[string]any{ + "sub": identity.ID.String(), + } + + for _, scope := range scopes { + switch scope { + case coredata.OAuth2ScopeEmail: + claims["email"] = identity.EmailAddress.String() + claims["email_verified"] = identity.EmailAddressVerified + case coredata.OAuth2ScopeProfile: + claims["name"] = identity.FullName + } + } + + return claims, nil +} + +func (s *Service) RevokeToken( + ctx context.Context, + clientID gid.GID, + tokenValue string, + tokenTypeHint *coredata.OAuth2TokenTypeHint, +) error { + if tokenValue == "" { + return nil + } + + hashedValue := hash.SHA256String(tokenValue) + + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if tokenTypeHint != nil && *tokenTypeHint == coredata.OAuth2TokenTypeHintRefreshToken { + refreshToken := coredata.OAuth2RefreshToken{} + err := refreshToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load refresh token: %w", err) + } + if err == nil { + now := time.Now() + if err := refreshToken.Revoke(ctx, tx, now); err != nil { + return fmt.Errorf("cannot revoke refresh token: %w", err) + } + + if refreshToken.AccessTokenID != gid.Nil { + at := coredata.OAuth2AccessToken{ID: refreshToken.AccessTokenID} + if err := at.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete linked access token: %w", err) + } + } + + return nil + } + + accessToken := coredata.OAuth2AccessToken{} + err = accessToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load access token: %w", err) + } + if err == nil { + if err := accessToken.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete access token: %w", err) + } + } + + return nil + } + + accessToken := coredata.OAuth2AccessToken{} + err := accessToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load access token: %w", err) + } + if err == nil { + if err := accessToken.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete access token: %w", err) + } + return nil + } + + refreshToken := coredata.OAuth2RefreshToken{} + err = refreshToken.LoadByHashedValueAndClientID(ctx, tx, hashedValue, clientID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load refresh token: %w", err) + } + if err == nil { + now := time.Now() + if err := refreshToken.Revoke(ctx, tx, now); err != nil { + return fmt.Errorf("cannot revoke refresh token: %w", err) + } + + if refreshToken.AccessTokenID != gid.Nil { + at := coredata.OAuth2AccessToken{ID: refreshToken.AccessTokenID} + if err := at.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete linked access token: %w", err) + } + } + } + + return nil + }, + ) +} + +func (s *Service) Authorize( + ctx context.Context, + req *AuthorizeRequest, +) (string, error) { + var code string + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var client coredata.OAuth2Client + if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), req.ClientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrClientNotFound + } + + return fmt.Errorf("cannot load client: %w", err) + } + + if !client.IsRedirectURIAllowed(req.RedirectURI) { + return ErrInvalidRedirectURI + } + + if client.Visibility == coredata.OAuth2ClientVisibilityPrivate { + if client.OrganizationID == nil { + return fmt.Errorf("cannot authorize: private client has no organization") + } + + var membership coredata.Membership + if err := membership.LoadActiveByIdentityIDAndOrganizationID( + ctx, + tx, + req.IdentityID, + *client.OrganizationID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrUnauthorizedMember + } + + return fmt.Errorf("cannot check membership: %w", err) + } + } + + if req.ResponseType != coredata.OAuth2ResponseTypeCode { + return fmt.Errorf("cannot authorize: unsupported response_type") + } + + requestedScopes := req.Scopes.OrDefault(client.Scopes) + if !client.AreScopesAllowed(requestedScopes) { + return fmt.Errorf("cannot authorize: requested scope exceeds client registration") + } + + if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { + return NewError( + ErrInvalidScope, + WithDescription("offline_access requires the refresh_token grant type"), + ) + } + + codeChallengeMethod := req.CodeChallengeMethod + if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone && req.CodeChallenge == "" { + return fmt.Errorf("cannot authorize: code_challenge required for public clients") + } + + if codeChallengeMethod != "" && codeChallengeMethod != coredata.OAuth2CodeChallengeMethodS256 { + return fmt.Errorf("cannot authorize: only S256 code_challenge_method is supported") + } + + if req.CodeChallenge != "" && codeChallengeMethod == "" { + codeChallengeMethod = coredata.OAuth2CodeChallengeMethodS256 + } + + // RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always require + // explicit user consent since they cannot be strongly authenticated. + if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone { + var existingConsent coredata.OAuth2Consent + if err := existingConsent.LoadMatchingConsent( + ctx, + tx, + req.IdentityID, + client.ID, + requestedScopes, + ); err == nil { + var err error + code, err = s.issueAuthorizationCode( + ctx, + tx, + &client, + req.IdentityID, + uri.URI(req.RedirectURI), + requestedScopes, + req.CodeChallenge, + codeChallengeMethod, + req.Nonce, + req.AuthTime, + ) + if err != nil { + return fmt.Errorf("cannot issue authorization code: %w", err) + } + + return nil + } + } + + now := time.Now() + pendingConsent := &coredata.OAuth2Consent{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2ConsentEntityType), + IdentityID: req.IdentityID, + SessionID: req.SessionID, + ClientID: client.ID, + Scopes: requestedScopes, + RedirectURI: new(uri.URI(req.RedirectURI)), + CodeChallenge: req.CodeChallenge, + CodeChallengeMethod: codeChallengeMethod, + Nonce: req.Nonce, + State: req.State, + Approved: false, + CreatedAt: now, + UpdatedAt: now, + } + + if err := pendingConsent.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot create pending consent: %w", err) + } + + return pg.NoRollback( + &ConsentRequiredError{ + ConsentID: pendingConsent.ID, + Client: &client, + Scopes: requestedScopes, + }, + ) + }, + ); err != nil { + if _, ok := errors.AsType[*ConsentRequiredError](err); ok { + return "", err + } + + return "", err + } + + return code, nil +} + +func (s *Service) GetConsentByID( + ctx context.Context, + consentID gid.GID, +) (*coredata.OAuth2Consent, error) { + var consent coredata.OAuth2Consent + if err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := consent.LoadByID(ctx, conn, consentID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewError(ErrInvalidRequest, WithDescription("consent not found")) + } + + return fmt.Errorf("cannot load consent: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + if consent.Approved { + return nil, NewError( + ErrInvalidRequest, + WithDescription("consent already processed"), + ) + } + + return &consent, nil +} + +type ConsentApprovalResult struct { + // Authorization code flow fields. + Code string + RedirectURI string + State string + + // Device flow: true when the consent was for a device code grant. + IsDeviceFlow bool + + // Denied is true when the user denied the consent request. + Denied bool +} + +func (s *Service) ApproveConsent( + ctx context.Context, + req *ConsentApprovalRequest, +) (*ConsentApprovalResult, error) { + var ( + consent coredata.OAuth2Consent + result ConsentApprovalResult + ) + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := consent.LoadByIDForSessionForUpdate(ctx, tx, req.ConsentID, req.IdentityID, req.SessionID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrConsentNotFound + } + + return fmt.Errorf("cannot load consent: %w", err) + } + + if consent.Approved { + return NewError( + ErrInvalidRequest, + WithDescription("consent already processed"), + ) + } + + var client coredata.OAuth2Client + if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), consent.ClientID); err != nil { + return fmt.Errorf("cannot load client: %w", err) + } + + isDeviceFlow := consent.DeviceCodeID != nil + redirectURI := string(ref.UnrefOrZero(consent.RedirectURI)) + + if !isDeviceFlow && !client.IsRedirectURIAllowed(redirectURI) { + return ErrInvalidRedirectURI + } + + var deviceCode coredata.OAuth2DeviceCode + if isDeviceFlow { + if err := deviceCode.LoadByIDForUpdate(ctx, tx, *consent.DeviceCodeID); err != nil { + return fmt.Errorf("cannot load device code: %w", err) + } + } + + if !req.Approved { + if isDeviceFlow { + deviceCode.Status = coredata.OAuth2DeviceCodeStatusDenied + deviceCode.IdentityID = &consent.IdentityID + + if err := deviceCode.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot deny device code: %w", err) + } + } + + if err := consent.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete consent: %w", err) + } + + result.Denied = true + result.IsDeviceFlow = isDeviceFlow + result.RedirectURI = redirectURI + result.State = consent.State + + return nil + } + + consent.Approved = true + consent.UpdatedAt = time.Now() + + if err := consent.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot approve consent: %w", err) + } + + if isDeviceFlow { + if deviceCode.Status != coredata.OAuth2DeviceCodeStatusPending { + return ErrDeviceCodeNotPending + } + + deviceCode.Status = coredata.OAuth2DeviceCodeStatusAuthorized + deviceCode.IdentityID = &consent.IdentityID + + if err := deviceCode.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot update device code: %w", err) + } + + result.IsDeviceFlow = true + return nil + } + + code, err := s.issueAuthorizationCode( + ctx, + tx, + &client, + consent.IdentityID, + ref.UnrefOrZero(consent.RedirectURI), + consent.Scopes, + consent.CodeChallenge, + consent.CodeChallengeMethod, + consent.Nonce, + req.AuthTime, + ) + if err != nil { + return fmt.Errorf("cannot issue authorization code: %w", err) + } + + result.Code = code + result.RedirectURI = redirectURI + result.State = consent.State + + return nil + }, + ); err != nil { + return nil, err + } + + return &result, nil +} + +func (s *Service) AuthenticateClient( + ctx context.Context, + clientID gid.GID, + clientSecret string, +) (*coredata.OAuth2Client, error) { + client, err := s.GetClientByID(ctx, clientID) + if err != nil { + return nil, NewError(ErrInvalidClient, WithDescription("cannot load client")) + } + + if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone { + return client, nil + } + + if clientSecret == "" { + return nil, NewError(ErrInvalidClient, WithDescription("missing client_secret")) + } + + if subtle.ConstantTimeCompare(client.ClientSecretHash, hash.SHA256String(clientSecret)) != 1 { + return nil, NewError(ErrInvalidClient, WithDescription("invalid client_secret")) + } + + return client, nil +} + +func (s *Service) issueAuthorizationCode( + ctx context.Context, + tx pg.Tx, + client *coredata.OAuth2Client, + identityID gid.GID, + redirectURI uri.URI, + scopes coredata.OAuth2Scopes, + codeChallenge string, + codeChallengeMethod coredata.OAuth2CodeChallengeMethod, + nonce string, + authTime time.Time, +) (string, error) { + codeValue := rand.MustHexString(tokenByteLength) + now := time.Now() + + code := &coredata.OAuth2AuthorizationCode{ + ID: gid.New(client.ID.TenantID(), coredata.OAuth2AuthorizationCodeEntityType), + HashedValue: hash.SHA256String(codeValue), + ClientID: client.ID, + IdentityID: identityID, + RedirectURI: redirectURI, + Scopes: scopes, + AuthTime: authTime, + CreatedAt: now, + ExpiresAt: now.Add(s.authorizationCodeDuration), + } + + if codeChallenge != "" { + code.CodeChallenge = &codeChallenge + code.CodeChallengeMethod = &codeChallengeMethod + } + + if nonce != "" { + code.Nonce = &nonce + } + + if err := code.Insert(ctx, tx); err != nil { + return "", fmt.Errorf("cannot insert authorization code: %w", err) + } + + return codeValue, nil +} diff --git a/pkg/iam/policy_set.go b/pkg/iam/policy_set.go index dbe5199f0..f3f98ab69 100644 --- a/pkg/iam/policy_set.go +++ b/pkg/iam/policy_set.go @@ -69,5 +69,6 @@ func IAMPolicySet() *PolicySet { IAMSelfManageProfilePolicy, IAMSelfManageMembershipPolicy, IAMSelfManagePersonalAPIKeyPolicy, + IAMSelfManageOAuth2ConsentPolicy, ) } diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 2cc14c996..aadac0530 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -16,9 +16,6 @@ package scim import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "net" @@ -37,6 +34,8 @@ import ( "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" + "go.probo.inc/probo/pkg/crypto/hash" + "go.probo.inc/probo/pkg/crypto/rand" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" @@ -88,16 +87,11 @@ func (s *Service) Run(ctx context.Context) error { } func HashToken(token string) []byte { - hash := sha256.Sum256([]byte(token)) - return hash[:] + return hash.SHA256String(token) } func GenerateToken() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("cannot generate random token: %w", err) - } - return hex.EncodeToString(bytes), nil + return rand.HexString(32) } // ValidateToken validates a bearer token and returns the SCIM configuration diff --git a/pkg/iam/service.go b/pkg/iam/service.go index dd50f2fe8..7d910715e 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -33,9 +33,11 @@ import ( "go.probo.inc/probo/pkg/crypto/passwdhash" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/iam/saml" "go.probo.inc/probo/pkg/iam/scim" + "go.probo.inc/probo/pkg/uri" ) type ( @@ -64,6 +66,7 @@ type ( OIDCService *oidc.Service SCIMService *scim.Service APIKeyService *APIKeyService + OAuth2ServerService *oauth2server.Service Authorizer *Authorizer samlDomainVerifier *SAMLDomainVerifier @@ -91,6 +94,8 @@ type ( SCIMBridgePollInterval time.Duration GoogleOIDC oidc.ProviderConfig MicrosoftOIDC oidc.ProviderConfig + OAuth2ServerSigningKeys oauth2server.SigningKeys + OAuth2ServerOptions []oauth2server.Option } ) @@ -177,6 +182,14 @@ func NewService( }, ) + svc.OAuth2ServerService = oauth2server.NewService( + pgClient, + cfg.OAuth2ServerSigningKeys, + uri.URI(cfg.BaseURL.String()), + cfg.Logger.Named("oauth2server"), + cfg.OAuth2ServerOptions..., + ) + svc.samlDomainVerifier = NewSAMLDomainVerifier( pgClient, cfg.Logger, @@ -233,12 +246,22 @@ func (s *Service) Run(ctx context.Context) error { }, ) + oauth2Ctx, stopOAuth2Server := context.WithCancel(context.WithoutCancel(ctx)) + wg.Go( + func() { + if err := s.OAuth2ServerService.Run(oauth2Ctx); err != nil { + cancel(fmt.Errorf("oauth2 server service crashed: %w", err)) + } + }, + ) + <-ctx.Done() stopSAML() stopOIDC() stopDomainVerifier() stopSCIM() + stopOAuth2Server() wg.Wait() diff --git a/pkg/llm/registry_gen.go b/pkg/llm/registry_gen.go index 5a725036c..b74bcd7ad 100644 --- a/pkg/llm/registry_gen.go +++ b/pkg/llm/registry_gen.go @@ -14,7 +14,7 @@ // Code generated by genmodels; DO NOT EDIT. // Source: https://openrouter.ai/api/v1/models -// Generated: 2026-04-13T15:17:27Z +// Generated: 2026-04-15T06:30:41Z package llm @@ -62,7 +62,7 @@ var generatedModels = map[string]ModelDefinition{ "google/gemma-4-26b-a4b-it": { Name: "Google: Gemma 4 26B A4B ", ContextLength: 262144, - MaxOutputTokens: 262144, + MaxOutputTokens: 0, Supports: SupportedParameters{ Temperature: true, TopP: true, @@ -102,7 +102,7 @@ var generatedModels = map[string]ModelDefinition{ "google/gemma-4-31b-it": { Name: "Google: Gemma 4 31B", ContextLength: 262144, - MaxOutputTokens: 131072, + MaxOutputTokens: 0, Supports: SupportedParameters{ Temperature: true, TopP: true, @@ -2939,46 +2939,6 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "meta-llama/llama-3.2-1b-instruct": { - Name: "Meta: Llama 3.2 1B Instruct", - ContextLength: 60000, - MaxOutputTokens: 0, - Supports: SupportedParameters{ - Temperature: true, - TopP: true, - TopK: true, - FrequencyPenalty: true, - PresencePenalty: true, - Stop: false, - Seed: true, - MaxTokens: true, - ToolChoice: false, - ParallelToolCalls: false, - ResponseFormat: false, - StructuredOutputs: false, - Reasoning: false, - }, - }, - "meta-llama/llama-3.2-11b-vision-instruct": { - Name: "Meta: Llama 3.2 11B Vision Instruct", - ContextLength: 131072, - MaxOutputTokens: 16384, - Supports: SupportedParameters{ - Temperature: true, - TopP: true, - TopK: true, - FrequencyPenalty: true, - PresencePenalty: true, - Stop: true, - Seed: true, - MaxTokens: true, - ToolChoice: false, - ParallelToolCalls: false, - ResponseFormat: true, - StructuredOutputs: false, - Reasoning: false, - }, - }, "meta-llama/llama-3.2-3b-instruct:free": { Name: "Meta: Llama 3.2 3B Instruct (free)", ContextLength: 131072, @@ -3019,6 +2979,46 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, + "meta-llama/llama-3.2-1b-instruct": { + Name: "Meta: Llama 3.2 1B Instruct", + ContextLength: 60000, + MaxOutputTokens: 0, + Supports: SupportedParameters{ + Temperature: true, + TopP: true, + TopK: true, + FrequencyPenalty: true, + PresencePenalty: true, + Stop: false, + Seed: true, + MaxTokens: true, + ToolChoice: false, + ParallelToolCalls: false, + ResponseFormat: false, + StructuredOutputs: false, + Reasoning: false, + }, + }, + "meta-llama/llama-3.2-11b-vision-instruct": { + Name: "Meta: Llama 3.2 11B Vision Instruct", + ContextLength: 131072, + MaxOutputTokens: 16384, + Supports: SupportedParameters{ + Temperature: true, + TopP: true, + TopK: true, + FrequencyPenalty: true, + PresencePenalty: true, + Stop: true, + Seed: true, + MaxTokens: true, + ToolChoice: false, + ParallelToolCalls: false, + ResponseFormat: true, + StructuredOutputs: false, + Reasoning: false, + }, + }, "openai/gpt-4o-2024-08-06": { Name: "OpenAI: GPT-4o (2024-08-06)", ContextLength: 128000, @@ -3159,26 +3159,6 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "google/gemma-2-9b-it": { - Name: "Google: Gemma 2 9B", - ContextLength: 8192, - MaxOutputTokens: 0, - Supports: SupportedParameters{ - Temperature: true, - TopP: true, - TopK: true, - FrequencyPenalty: true, - PresencePenalty: true, - Stop: false, - Seed: false, - MaxTokens: true, - ToolChoice: false, - ParallelToolCalls: false, - ResponseFormat: false, - StructuredOutputs: false, - Reasoning: false, - }, - }, "openai/gpt-4o-2024-05-13": { Name: "OpenAI: GPT-4o (2024-05-13)", ContextLength: 128000, @@ -3359,9 +3339,9 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "openai/gpt-3.5-turbo-0613": { - Name: "OpenAI: GPT-3.5 Turbo (older v0613)", - ContextLength: 4095, + "openai/gpt-4-turbo-preview": { + Name: "OpenAI: GPT-4 Turbo Preview", + ContextLength: 128000, MaxOutputTokens: 4096, Supports: SupportedParameters{ Temperature: true, @@ -3379,9 +3359,9 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "openai/gpt-4-turbo-preview": { - Name: "OpenAI: GPT-4 Turbo Preview", - ContextLength: 128000, + "openai/gpt-3.5-turbo-0613": { + Name: "OpenAI: GPT-3.5 Turbo (older v0613)", + ContextLength: 4095, MaxOutputTokens: 4096, Supports: SupportedParameters{ Temperature: true, @@ -3439,26 +3419,6 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "mistralai/mistral-7b-instruct-v0.1": { - Name: "Mistral: Mistral 7B Instruct v0.1", - ContextLength: 2824, - MaxOutputTokens: 0, - Supports: SupportedParameters{ - Temperature: true, - TopP: true, - TopK: true, - FrequencyPenalty: true, - PresencePenalty: true, - Stop: false, - Seed: true, - MaxTokens: true, - ToolChoice: false, - ParallelToolCalls: false, - ResponseFormat: false, - StructuredOutputs: false, - Reasoning: false, - }, - }, "openai/gpt-3.5-turbo-instruct": { Name: "OpenAI: GPT-3.5 Turbo Instruct", ContextLength: 4095, @@ -3479,6 +3439,26 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, + "mistralai/mistral-7b-instruct-v0.1": { + Name: "Mistral: Mistral 7B Instruct v0.1", + ContextLength: 2824, + MaxOutputTokens: 0, + Supports: SupportedParameters{ + Temperature: true, + TopP: true, + TopK: true, + FrequencyPenalty: true, + PresencePenalty: true, + Stop: false, + Seed: true, + MaxTokens: true, + ToolChoice: false, + ParallelToolCalls: false, + ResponseFormat: false, + StructuredOutputs: false, + Reasoning: false, + }, + }, "openai/gpt-3.5-turbo-16k": { Name: "OpenAI: GPT-3.5 Turbo 16k", ContextLength: 16385, @@ -3519,26 +3499,6 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, - "openai/gpt-3.5-turbo": { - Name: "OpenAI: GPT-3.5 Turbo", - ContextLength: 16385, - MaxOutputTokens: 4096, - Supports: SupportedParameters{ - Temperature: true, - TopP: true, - TopK: false, - FrequencyPenalty: true, - PresencePenalty: true, - Stop: true, - Seed: true, - MaxTokens: true, - ToolChoice: true, - ParallelToolCalls: false, - ResponseFormat: true, - StructuredOutputs: true, - Reasoning: false, - }, - }, "openai/gpt-4": { Name: "OpenAI: GPT-4", ContextLength: 8191, @@ -3559,4 +3519,24 @@ var generatedModels = map[string]ModelDefinition{ Reasoning: false, }, }, + "openai/gpt-3.5-turbo": { + Name: "OpenAI: GPT-3.5 Turbo", + ContextLength: 16385, + MaxOutputTokens: 4096, + Supports: SupportedParameters{ + Temperature: true, + TopP: true, + TopK: false, + FrequencyPenalty: true, + PresencePenalty: true, + Stop: true, + Seed: true, + MaxTokens: true, + ToolChoice: true, + ParallelToolCalls: false, + ResponseFormat: true, + StructuredOutputs: true, + Reasoning: false, + }, + }, } diff --git a/pkg/net/net.go b/pkg/net/net.go new file mode 100644 index 000000000..2308be8c6 --- /dev/null +++ b/pkg/net/net.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 net + +import "net" + +// IsLoopback reports whether host is a loopback address. It recognizes +// "localhost" by name and delegates to net.IP.IsLoopback for IP addresses, +// which covers 127.0.0.0/8, ::1, and IPv4-mapped variants like +// ::ffff:127.0.0.1. +func IsLoopback(host string) bool { + if host == "localhost" { + return true + } + + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/pkg/net/net_test.go b/pkg/net/net_test.go new file mode 100644 index 000000000..24934e747 --- /dev/null +++ b/pkg/net/net_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 net_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/net" +) + +func TestIsLoopback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + want bool + }{ + // Localhost by name. + {"localhost", "localhost", true}, + + // IPv4 loopback addresses (127.0.0.0/8). + {"ipv4 canonical loopback", "127.0.0.1", true}, + {"ipv4 loopback high octet", "127.255.255.255", true}, + {"ipv4 loopback alternate", "127.0.0.2", true}, + {"ipv4 loopback 127.1.2.3", "127.1.2.3", true}, + + // IPv6 loopback. + {"ipv6 loopback", "::1", true}, + + // IPv4-mapped IPv6 loopback. + {"ipv4-mapped ipv6 loopback", "::ffff:127.0.0.1", true}, + {"ipv4-mapped ipv6 loopback alternate", "::ffff:127.0.0.2", true}, + + // Non-loopback addresses. + {"ipv4 private 10.x", "10.0.0.1", false}, + {"ipv4 private 192.168.x", "192.168.1.1", false}, + {"ipv4 private 172.16.x", "172.16.0.1", false}, + {"ipv4 public", "8.8.8.8", false}, + {"ipv4 all interfaces", "0.0.0.0", false}, + {"ipv6 all interfaces", "::", false}, + {"ipv6 link-local", "fe80::1", false}, + {"ipv6 public", "2001:db8::1", false}, + {"ipv4-mapped ipv6 non-loopback", "::ffff:192.168.1.1", false}, + + // Hostnames that are not localhost. + {"example.com", "example.com", false}, + {"localhost.localdomain", "localhost.localdomain", false}, + {"myhost", "myhost", false}, + + // Edge cases. + {"empty string", "", false}, + {"whitespace", " ", false}, + {"localhost with trailing dot", "localhost.", false}, + {"uppercase LOCALHOST", "LOCALHOST", false}, + {"mixed case Localhost", "Localhost", false}, + {"128.0.0.1 not loopback", "128.0.0.1", false}, + {"126.255.255.255 not loopback", "126.255.255.255", false}, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, net.IsLoopback(tt.host)) + }, + ) + } +} diff --git a/pkg/probod/auth_config.go b/pkg/probod/auth_config.go index 1755d60d1..c0e9b4807 100644 --- a/pkg/probod/auth_config.go +++ b/pkg/probod/auth_config.go @@ -29,6 +29,21 @@ type AuthConfig struct { SAML SAMLConfig `json:"saml"` Google OIDCProviderConfig `json:"google"` Microsoft OIDCProviderConfig `json:"microsoft"` + OAuth2Server OAuth2ServerConfig `json:"oauth2-server"` +} + +type OAuth2ServerConfig struct { + SigningKeys []OAuth2SigningKeyConfig `json:"signing-keys"` + AccessTokenDuration int `json:"access-token-duration"` + RefreshTokenDuration int `json:"refresh-token-duration"` + AuthorizationCodeDuration int `json:"authorization-code-duration"` + DeviceCodeDuration int `json:"device-code-duration"` +} + +type OAuth2SigningKeyConfig struct { + KeyFile string `json:"key-file"` + KID string `json:"kid"` + Active bool `json:"active"` } type CookieConfig struct { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index dc2de0b62..9d295ea14 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -25,6 +25,7 @@ import ( "fmt" "net" "net/http" + "os" "strings" "sync" "time" @@ -58,6 +59,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" @@ -379,6 +381,48 @@ func (impl *Implm) Run( } } + if len(impl.cfg.Auth.OAuth2Server.SigningKeys) == 0 { + return fmt.Errorf("cannot configure OAuth2 server: at least one signing key is required") + } + + var oauth2SigningKeys oauth2server.SigningKeys + var hasActive bool + for _, keyCfg := range impl.cfg.Auth.OAuth2Server.SigningKeys { + keyPEM, err := os.ReadFile(keyCfg.KeyFile) + if err != nil { + return fmt.Errorf("cannot read OAuth2 server signing key file: %w", err) + } + + signer, err := pemutil.DecodePrivateKey(keyPEM) + if err != nil { + return fmt.Errorf("cannot decode OAuth2 server signing key: %w", err) + } + + rsaKey, ok := signer.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("OAuth2 server signing key is not an RSA key") + } + + kid := keyCfg.KID + if kid == "" { + kid = "default" + } + + if keyCfg.Active { + hasActive = true + } + + oauth2SigningKeys = append(oauth2SigningKeys, oauth2server.SigningKey{ + PrivateKey: rsaKey, + KID: kid, + Active: keyCfg.Active, + }) + } + + if !hasActive { + return fmt.Errorf("cannot configure OAuth2 server: at least one signing key must be active") + } + if err := emails.UploadStaticAssets( ctx, s3Client, @@ -422,6 +466,8 @@ func (impl *Implm) Run( ClientSecret: impl.cfg.Auth.Microsoft.ClientSecret, Enabled: impl.cfg.Auth.Microsoft.Enabled, }, + OAuth2ServerSigningKeys: oauth2SigningKeys, + OAuth2ServerOptions: oauth2ServerOptions(impl.cfg.Auth.OAuth2Server), }, ) if err != nil { @@ -1111,3 +1157,25 @@ func parseIPs(strs []string) []net.IP { } return ips } + +func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2server.Option { + var opts []oauth2server.Option + + if cfg.AccessTokenDuration > 0 { + opts = append(opts, oauth2server.WithAccessTokenDuration(time.Duration(cfg.AccessTokenDuration)*time.Second)) + } + + if cfg.RefreshTokenDuration > 0 { + opts = append(opts, oauth2server.WithRefreshTokenDuration(time.Duration(cfg.RefreshTokenDuration)*time.Second)) + } + + if cfg.AuthorizationCodeDuration > 0 { + opts = append(opts, oauth2server.WithAuthorizationCodeDuration(time.Duration(cfg.AuthorizationCodeDuration)*time.Second)) + } + + if cfg.DeviceCodeDuration > 0 { + opts = append(opts, oauth2server.WithDeviceCodeDuration(time.Duration(cfg.DeviceCodeDuration)*time.Second)) + } + + return opts +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index e505906c7..58d0edba5 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -145,6 +145,13 @@ func NewServer(cfg Config) (*Server, error) { csrf.AddInsecureBypassPattern("POST /cookie-banner/v1/*") csrf.AddInsecureBypassPattern("OPTIONS /cookie-banner/v1/*") + // OAuth2 token, introspection, revocation, and device authorization + // endpoints receive cross-origin POSTs from external clients. + csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/token") + csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/introspect") + csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/revoke") + csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/device") + csrf.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { httpserver.RenderJSON( w, diff --git a/pkg/server/api/authn/oauth2_access_token_middleware.go b/pkg/server/api/authn/oauth2_access_token_middleware.go new file mode 100644 index 000000000..e123c0227 --- /dev/null +++ b/pkg/server/api/authn/oauth2_access_token_middleware.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 authn + +import ( + "fmt" + "net/http" + + "go.probo.inc/probo/pkg/bearertoken" + "go.probo.inc/probo/pkg/iam" +) + +func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if IdentityFromContext(ctx) != nil { + next.ServeHTTP(w, r) + return + } + + tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization")) + if err != nil { + next.ServeHTTP(w, r) + return + } + + accessToken, err := svc.OAuth2ServerService.LoadAccessToken(ctx, tokenValue) + if err != nil { + next.ServeHTTP(w, r) + return + } + + identity, err := svc.AccountService.GetIdentity(ctx, accessToken.IdentityID) + if err != nil { + panic(fmt.Errorf("cannot get identity for oauth2 access token: %w", err)) + } + + ctx = ContextWithIdentity(ctx, identity) + + next.ServeHTTP(w, r.WithContext(ctx)) + }, + ) + } +} diff --git a/pkg/server/api/connect/v1/base_resolvers.go b/pkg/server/api/connect/v1/base_resolvers.go index 834914317..594c45981 100644 --- a/pkg/server/api/connect/v1/base_resolvers.go +++ b/pkg/server/api/connect/v1/base_resolvers.go @@ -16,6 +16,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" @@ -31,6 +32,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error ) switch id.EntityType() { + case coredata.OAuth2ConsentEntityType: + action = iam.ActionOAuth2ConsentGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + consent, err := r.iam.OAuth2ServerService.GetConsentByID(ctx, id) + if err != nil { + return nil, err + } + + return types.NewConsent(consent), nil + } case coredata.OrganizationEntityType: action = iam.ActionOrganizationGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { @@ -38,6 +49,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error if err != nil { return nil, err } + return types.NewOrganization(organization), nil } case coredata.IdentityEntityType: @@ -157,6 +169,10 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return nil, gqlutils.NotFound(ctx, err) } + if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description()) + } + r.logger.ErrorCtx(ctx, "cannot load node", log.Error(err)) return nil, gqlutils.Internal(ctx) } diff --git a/pkg/server/api/connect/v1/cache.go b/pkg/server/api/connect/v1/cache.go new file mode 100644 index 000000000..03af7cc65 --- /dev/null +++ b/pkg/server/api/connect/v1/cache.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 connect_v1 + +import ( + "fmt" + "net/http" + "time" +) + +func NoCache(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + +func PublicCache(w http.ResponseWriter, maxAge time.Duration) { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds()))) +} diff --git a/pkg/server/api/connect/v1/gqlgen.yaml b/pkg/server/api/connect/v1/gqlgen.yaml index 3ab6b280a..75e4034ae 100644 --- a/pkg/server/api/connect/v1/gqlgen.yaml +++ b/pkg/server/api/connect/v1/gqlgen.yaml @@ -37,4 +37,4 @@ models: - "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar" EmailAddr: model: - - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" + - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" \ No newline at end of file diff --git a/pkg/server/api/connect/v1/graphql/oauth2.graphql b/pkg/server/api/connect/v1/graphql/oauth2.graphql new file mode 100644 index 000000000..b8ec5f0db --- /dev/null +++ b/pkg/server/api/connect/v1/graphql/oauth2.graphql @@ -0,0 +1,42 @@ +extend type Mutation { + authorizeDevice( + input: AuthorizeDeviceInput! + ): AuthorizeDevicePayload @session(required: PRESENT) + + approveConsent( + input: ApproveConsentInput! + ): ApproveConsentPayload @session(required: PRESENT) +} + + +input AuthorizeDeviceInput { + userCode: String! +} + +type AuthorizeDevicePayload { + success: Boolean! + consentId: ID +} + +type Consent implements Node { + id: ID! + application: Application! @goField(forceResolver: true) + scopes: [String!]! +} + +type Application implements Node { + id: ID! + name: String! + logoUrl: String + url: String +} + +input ApproveConsentInput { + consentId: ID! + approved: Boolean! +} + +type ApproveConsentPayload { + redirectURL: String + deviceAuthorized: Boolean +} diff --git a/pkg/server/api/connect/v1/oauth2_error.go b/pkg/server/api/connect/v1/oauth2_error.go new file mode 100644 index 000000000..28a7bd27e --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_error.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 connect_v1 + +import ( + "errors" + "net/http" + "net/url" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/server/api/connect/v1/types" +) + +func (h *OAuth2Handler) handleAuthorizeError(w http.ResponseWriter, r *http.Request, err error, redirectURI, state string) { + if isRedirectableError(err) && redirectURI != "" { + redirectWithError(w, r, redirectURI, state, err) + return + } + + h.renderOAuth2ErrorResponse(w, r, err) +} + +func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err) + if !ok { + httpserver.RenderError(w, http.StatusInternalServerError, err) + return + } + + if errors.Is(err, oauth2server.ErrServerError) { + h.logger.ErrorCtx(r.Context(), "oauth2 server error", log.Error(err)) + } + + NoCache(w) + + httpserver.RenderJSON(w, oauth2ErrorStatusCode(oauthErr), &types.OAuth2ErrorResponse{ + Code: oauthErr.ErrorCode(), + Description: oauthErr.Description(), + }) +} + +func isRedirectableError(err error) bool { + return errors.Is(err, oauth2server.ErrAccessDenied) || + errors.Is(err, oauth2server.ErrInvalidRequest) || + errors.Is(err, oauth2server.ErrInvalidScope) || + errors.Is(err, oauth2server.ErrUnauthorizedClient) || + errors.Is(err, oauth2server.ErrInvalidGrant) || + errors.Is(err, oauth2server.ErrUnsupportedGrantType) +} + +func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int { + switch err.ErrorCode() { + case "access_denied": + return http.StatusForbidden + case "invalid_client": + return http.StatusUnauthorized + case "server_error": + return http.StatusInternalServerError + default: + return http.StatusBadRequest + } +} + +func toOAuth2Error(err error) *oauth2server.OAuth2Error { + switch { + case errors.Is(err, oauth2server.ErrClientNotFound): + return oauth2server.NewError(oauth2server.ErrInvalidClient, oauth2server.WithDescription("client not found")) + case errors.Is(err, oauth2server.ErrInvalidRedirectURI): + return oauth2server.ErrInvalidRedirectURI + case errors.Is(err, oauth2server.ErrUnauthorizedMember): + return oauth2server.NewError(oauth2server.ErrUnauthorizedClient, oauth2server.WithDescription("client is private and user is not a member of the organization")) + case errors.Is(err, oauth2server.ErrDeviceCodeNotPending): + return oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("device code is not pending")) + default: + if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + return oauthErr + } + return oauth2server.NewError(oauth2server.ErrServerError, oauth2server.WithDescription("internal error")) + } +} + +func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, state string, err error) { + u, parseErr := url.Parse(redirectURI) + if parseErr != nil { + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + return + } + + oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err) + if !ok { + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + return + } + + q := u.Query() + q.Set("error", oauthErr.ErrorCode()) + if desc := oauthErr.Description(); desc != "" { + q.Set("error_description", desc) + } + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go new file mode 100644 index 000000000..6d7d50104 --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -0,0 +1,547 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 connect_v1 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/bearertoken" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/connect/v1/types" + "go.probo.inc/probo/pkg/uri" +) + +var ( + oauth2ClientContextKey = &ctxKey{name: "oauth2_client"} + oauth2AccessTokenContextKey = &ctxKey{name: "oauth2_access_token"} +) + +type OAuth2Handler struct { + iam *iam.Service + sessionCookie *authn.Cookie + baseURL *baseurl.BaseURL + logger *log.Logger +} + +func NewOAuth2Handler( + svc *iam.Service, + cookieConfig securecookie.Config, + baseURL *baseurl.BaseURL, + logger *log.Logger, +) *OAuth2Handler { + return &OAuth2Handler{ + iam: svc, + sessionCookie: authn.NewCookie(&cookieConfig), + baseURL: baseURL, + logger: logger.Named("oauth2"), + } +} + +// oauth2ClientFromContext returns the authenticated OAuth2 client from context. +func oauth2ClientFromContext(r *http.Request) *coredata.OAuth2Client { + client, _ := r.Context().Value(oauth2ClientContextKey).(*coredata.OAuth2Client) + return client +} + +// oauth2AccessTokenFromContext returns the validated OAuth2 access token from context. +func oauth2AccessTokenFromContext(r *http.Request) *coredata.OAuth2AccessToken { + token, _ := r.Context().Value(oauth2AccessTokenContextKey).(*coredata.OAuth2AccessToken) + return token +} + +// ClientAuthMiddleware authenticates the OAuth2 client from HTTP Basic auth +// or POST body credentials and stores it in the request context. +func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client, err := h.authenticateClient(r) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + return + } + + ctx := context.WithValue(r.Context(), oauth2ClientContextKey, client) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// BearerTokenMiddleware validates the OAuth2 bearer token from the +// Authorization header and stores the access token in the request context. +func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization")) + if err != nil { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + accessToken, err := h.iam.OAuth2ServerService.LoadAccessToken(r.Context(), tokenValue) + if err != nil { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), oauth2AccessTokenContextKey, accessToken) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (h *OAuth2Handler) endpoints() oauth2server.Endpoints { + api := h.baseURL.String() + "/api/connect/v1" + + return oauth2server.Endpoints{ + Authorization: uri.URI(api + "/oauth2/authorize"), + Token: uri.URI(api + "/oauth2/token"), + Userinfo: uri.URI(api + "/oauth2/userinfo"), + JWKS: uri.URI(api + "/oauth2/jwks"), + Registration: uri.URI(api + "/oauth2/register"), + Introspection: uri.URI(api + "/oauth2/introspect"), + Revocation: uri.URI(api + "/oauth2/revoke"), + DeviceAuthorization: uri.URI(api + "/oauth2/device"), + } +} + +// --- Handlers --- + +// DiscoveryHandler serves the OpenID Connect Discovery document. +// GET /.well-known/openid-configuration +func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) { + metadata := h.iam.OAuth2ServerService.Metadata(h.endpoints()) + + PublicCache(w, 1*time.Hour) + httpserver.RenderJSON(w, http.StatusOK, metadata) +} + +// JWKSHandler serves the JSON Web Key Set. +// GET /oauth2/jwks +func (h *OAuth2Handler) JWKSHandler(w http.ResponseWriter, r *http.Request) { + jwks := h.iam.OAuth2ServerService.JWKS() + + PublicCache(w, 1*time.Hour) + httpserver.RenderJSON(w, http.StatusOK, jwks) +} + +// AuthorizeHandler handles the authorization endpoint. +// GET /oauth2/authorize +func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) { + identity := authn.IdentityFromContext(r.Context()) + if identity == nil { + continueURL := h.baseURL.WithPath("/api/connect/v1/oauth2/authorize"). + WithQueryValues(r.URL.Query()). + MustString() + loginURL := h.baseURL.WithPath("/auth/login"). + WithQuery("continue", continueURL). + MustString() + http.Redirect(w, r, loginURL, http.StatusFound) + return + } + + var in types.OAuth2AuthorizeInput + if err := in.DecodeQuery(r.URL.Query()); err != nil { + h.handleAuthorizeError(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)), "", "") + return + } + + session := authn.SessionFromContext(r.Context()) + authTime := time.Now() + if session != nil { + authTime = session.CreatedAt + } + + code, err := h.iam.OAuth2ServerService.Authorize( + r.Context(), + &oauth2server.AuthorizeRequest{ + IdentityID: identity.ID, + SessionID: session.ID, + ResponseType: in.ResponseType, + ClientID: in.ClientID, + RedirectURI: in.RedirectURI, + Scopes: in.Scopes, + CodeChallenge: in.CodeChallenge, + CodeChallengeMethod: in.CodeChallengeMethod, + Nonce: in.Nonce, + State: in.State, + AuthTime: authTime, + }, + ) + + if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok { + consentURL := h.baseURL.WithPath("/auth/consent"). + WithQuery("consent_id", consentErr.ConsentID.String()). + MustString() + http.Redirect(w, r, consentURL, http.StatusFound) + return + } + + if err != nil { + oauthErr := toOAuth2Error(err) + h.handleAuthorizeError(w, r, oauthErr, in.RedirectURI, in.State) + return + } + + redirectWithCode(w, r, in.RedirectURI, code, in.State) +} + +func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid form data"))) + return + } + + var ( + grantType coredata.OAuth2GrantType + value = r.FormValue("grant_type") + ) + + if err := grantType.UnmarshalText([]byte(value)); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrUnsupportedGrantType) + return + } + + switch grantType { + case coredata.OAuth2GrantTypeAuthorizationCode: + h.handleAuthorizationCodeGrant(w, r) + case coredata.OAuth2GrantTypeRefreshToken: + h.handleRefreshTokenGrant(w, r) + case coredata.OAuth2GrantTypeDeviceCode: + h.handleDeviceCodeGrant(w, r) + default: + panic(fmt.Sprintf("unsupported grant type: %s", grantType)) + } +} + +func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) { + var ( + client = oauth2ClientFromContext(r) + in = types.OAuth2IntrospectInput{} + ) + + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + return + } + + result, err := h.iam.OAuth2ServerService.IntrospectToken( + r.Context(), + client.ID, + in.Token, + ) + if err != nil || result == nil { + httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse()) + return + } + + httpserver.RenderJSON(w, http.StatusOK, types.ActiveIntrospectResponse(result)) +} + +func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) { + var ( + client = oauth2ClientFromContext(r) + in = types.OAuth2RevokeInput{} + ) + + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + return + } + + if err := h.iam.OAuth2ServerService.RevokeToken( + r.Context(), + client.ID, + in.Token, + in.TokenTypeHint, + ); err != nil { + h.logger.ErrorCtx(r.Context(), "cannot revoke token", log.Error(err)) + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) +} + +// DeviceAuthHandler handles the device authorization endpoint (RFC 8628). +// POST /oauth2/device +func (h *OAuth2Handler) DeviceAuthHandler(w http.ResponseWriter, r *http.Request) { + in := types.OAuth2DeviceAuthInput{} + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + return + } + + deviceCodeValue, dc, err := h.iam.OAuth2ServerService.CreateDeviceCode( + r.Context(), + in.ClientID, + in.Scopes, + ) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, err) + return + } + + verificationURI := uri.URI(h.baseURL.WithPath("/auth/device").MustString()) + verificationURIComplete := uri.URI( + h.baseURL.WithPath("/auth/device"). + WithQuery("user_code", string(dc.UserCode)). + MustString(), + ) + + httpserver.RenderJSON( + w, + http.StatusOK, + &types.OAuth2DeviceAuthResponse{ + DeviceCode: deviceCodeValue, + UserCode: dc.UserCode.Format(), + VerificationURI: verificationURI, + VerificationURIComplete: verificationURIComplete, + ExpiresIn: int(time.Until(dc.ExpiresAt).Seconds()), + Interval: dc.PollInterval, + }, + ) +} + +// RegisterHandler handles dynamic client registration (RFC 7591). +// POST /oauth2/register +func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) { + identity := authn.IdentityFromContext(r.Context()) + + var in types.OAuth2RegisterInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + h.renderOAuth2ErrorResponse( + w, + r, + oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid JSON body")), + ) + return + } + + if len(in.GrantTypes) == 0 { + in.GrantTypes = []coredata.OAuth2GrantType{coredata.OAuth2GrantTypeAuthorizationCode} + } + if len(in.ResponseTypes) == 0 { + in.ResponseTypes = []coredata.OAuth2ResponseType{coredata.OAuth2ResponseTypeCode} + } + if in.TokenEndpointAuthMethod == "" { + in.TokenEndpointAuthMethod = coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic + } + if in.Visibility == "" { + in.Visibility = coredata.OAuth2ClientVisibilityPrivate + } + if len(in.Scopes) == 0 { + in.Scopes = coredata.OAuth2Scopes{ + coredata.OAuth2ScopeOpenID, + coredata.OAuth2ScopeProfile, + coredata.OAuth2ScopeEmail, + } + } + + clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient( + r.Context(), + &oauth2server.RegisterClientRequest{ + IdentityID: identity.ID, + OrganizationID: in.OrganizationID, + ClientName: in.ClientName, + Visibility: in.Visibility, + RedirectURIs: in.RedirectURIs, + GrantTypes: in.GrantTypes, + ResponseTypes: in.ResponseTypes, + TokenEndpointAuthMethod: in.TokenEndpointAuthMethod, + LogoURI: in.LogoURI, + ClientURI: in.ClientURI, + Contacts: in.Contacts, + Scopes: in.Scopes, + }, + ) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, err) + return + } + + httpserver.RenderJSON( + w, + http.StatusCreated, + &types.OAuth2RegisterResponse{ + ClientID: clientID.String(), + ClientSecret: clientSecret, + ClientName: in.ClientName, + Visibility: in.Visibility, + RedirectURIs: in.RedirectURIs, + GrantTypes: in.GrantTypes, + ResponseTypes: in.ResponseTypes, + TokenEndpointAuthMethod: in.TokenEndpointAuthMethod, + Scopes: in.Scopes, + }, + ) +} + +// UserInfoHandler serves the OIDC UserInfo endpoint. +// GET /oauth2/userinfo +func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) { + accessToken := oauth2AccessTokenFromContext(r) + + claims, err := h.iam.OAuth2ServerService.UserInfo( + r.Context(), + accessToken.IdentityID, + accessToken.Scopes, + ) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrServerError) + return + } + + httpserver.RenderJSON(w, http.StatusOK, claims) +} + +// --- Internal helpers --- + +func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Client, error) { + if err := r.ParseForm(); err != nil { + return nil, err + } + + var clientIDStr, clientSecret string + + // Try HTTP Basic auth first. + if username, password, ok := r.BasicAuth(); ok { + clientIDStr = username + clientSecret = password + } else { + // Fall back to POST body. + clientIDStr = r.FormValue("client_id") + clientSecret = r.FormValue("client_secret") + } + + if clientIDStr == "" { + return nil, oauth2server.ErrInvalidClient + } + + clientID, err := gid.ParseGID(clientIDStr) + if err != nil { + return nil, oauth2server.ErrInvalidClient + } + + return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret) +} + +func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + client, err := h.authenticateClient(r) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + return + } + + var in types.OAuth2AuthorizationCodeGrantInput + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err))) + return + } + + result, err := h.iam.OAuth2ServerService.ExchangeAuthorizationCode( + r.Context(), + client, + in.Code, + in.RedirectURI, + in.CodeVerifier, + ) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired code"))) + return + } + + NoCache(w) + httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result)) +} + +func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + client, err := h.authenticateClient(r) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + return + } + + var in types.OAuth2RefreshTokenGrantInput + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err))) + return + } + + result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired refresh token"))) + return + } + + NoCache(w) + httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result)) +} + +func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request) { + var in types.OAuth2DeviceCodeGrantInput + if err := in.DecodeForm(r); err != nil { + h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + return + } + + result, err := h.iam.OAuth2ServerService.PollDeviceCode( + r.Context(), + in.ClientID, + in.DeviceCode, + ) + if err != nil { + h.renderOAuth2ErrorResponse(w, r, err) + return + } + + NoCache(w) + httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result)) +} + +func tokenResultToResponse(r *oauth2server.TokenResult) *types.OAuth2TokenResponse { + return &types.OAuth2TokenResponse{ + AccessToken: r.AccessToken, + TokenType: r.TokenType, + ExpiresIn: r.ExpiresIn, + RefreshToken: r.RefreshToken, + IDToken: r.IDToken, + Scope: r.Scope, + } +} + +func redirectWithCode(w http.ResponseWriter, r *http.Request, redirectURI, code, state string) { + u, _ := url.Parse(redirectURI) + q := u.Query() + q.Set("code", code) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/pkg/server/api/connect/v1/oauth2_resolvers.go b/pkg/server/api/connect/v1/oauth2_resolvers.go new file mode 100644 index 000000000..754335367 --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_resolvers.go @@ -0,0 +1,126 @@ +package connect_v1 + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.87 + +import ( + "context" + "errors" + "net/url" + "strings" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/connect/v1/schema" + "go.probo.inc/probo/pkg/server/api/connect/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +// Application is the resolver for the application field. +func (r *consentResolver) Application(ctx context.Context, obj *types.Consent) (*types.Application, error) { + client, err := r.iam.OAuth2ServerService.GetClientByID(ctx, obj.Application.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load oauth2 client", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewApplication(client), nil +} + +// AuthorizeDevice is the resolver for the authorizeDevice field. +func (r *mutationResolver) AuthorizeDevice(ctx context.Context, input types.AuthorizeDeviceInput) (*types.AuthorizeDevicePayload, error) { + identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) + + userCode := strings.ToUpper(strings.TrimSpace(strings.ReplaceAll(input.UserCode, "-", ""))) + + err := r.iam.OAuth2ServerService.AuthorizeDevice(ctx, identity.ID, session.ID, userCode) + if err != nil { + if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok { + return &types.AuthorizeDevicePayload{ + ConsentID: &consentErr.ConsentID, + }, nil + } + + if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description()) + } + + r.logger.ErrorCtx(ctx, "cannot authorize device", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.AuthorizeDevicePayload{ + Success: true, + }, nil +} + +// ApproveConsent is the resolver for the approveConsent field. +func (r *mutationResolver) ApproveConsent(ctx context.Context, input types.ApproveConsentInput) (*types.ApproveConsentPayload, error) { + identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) + + result, err := r.iam.OAuth2ServerService.ApproveConsent( + ctx, + &oauth2server.ConsentApprovalRequest{ + ConsentID: input.ConsentID, + IdentityID: identity.ID, + SessionID: session.ID, + Approved: input.Approved, + AuthTime: session.CreatedAt, + }, + ) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot approve oauth2 consent", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + if result.Denied { + if result.IsDeviceFlow { + return &types.ApproveConsentPayload{ + DeviceAuthorized: new(false), + }, nil + } + + u, _ := url.Parse(result.RedirectURI) + q := u.Query() + q.Set("error", "access_denied") + q.Set("error_description", "user denied the request") + if result.State != "" { + q.Set("state", result.State) + } + u.RawQuery = q.Encode() + + redirectURL := u.String() + return &types.ApproveConsentPayload{ + RedirectURL: &redirectURL, + }, nil + } + + if result.IsDeviceFlow { + return &types.ApproveConsentPayload{ + DeviceAuthorized: new(true), + }, nil + } + + u, _ := url.Parse(result.RedirectURI) + q := u.Query() + q.Set("code", result.Code) + if result.State != "" { + q.Set("state", result.State) + } + u.RawQuery = q.Encode() + + redirectURL := u.String() + return &types.ApproveConsentPayload{ + RedirectURL: &redirectURL, + }, nil +} + +// Consent returns schema.ConsentResolver implementation. +func (r *Resolver) Consent() schema.ConsentResolver { return &consentResolver{r} } + +type consentResolver struct{ *Resolver } diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 828d6b756..9905d83ed 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -69,11 +69,12 @@ func NewMux( sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig) apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret) + oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc) graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig) samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger) scimHandler := NewSCIMHandler(svc, logger.Named("scim")) - router := r.With(sessionMiddleware, apiKeyMiddleware) + router := r.With(sessionMiddleware, apiKeyMiddleware, oauth2Middleware) oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost, isTrustCenterDomain) @@ -88,6 +89,29 @@ func NewMux( scimServer := NewSCIMServer(scimHandler) r.Mount("/scim/2.0", http.StripPrefix("/scim/2.0", scimHandler.BearerTokenMiddleware(scimServer))) + // OAuth2 / OpenID Connect server endpoints. + oauth2Handler := NewOAuth2Handler(svc, cookieConfig, baseURL, logger) + + // Public endpoints (no authentication). + r.Get("/oauth2/jwks", oauth2Handler.JWKSHandler) + r.Post("/oauth2/token", oauth2Handler.TokenHandler) + r.Post("/oauth2/device", oauth2Handler.DeviceAuthHandler) + + // Bearer-token authenticated endpoints. + bearerAuth := r.With(oauth2Handler.BearerTokenMiddleware) + bearerAuth.Get("/oauth2/userinfo", oauth2Handler.UserInfoHandler) + + // Client-authenticated endpoints. + clientAuth := r.With(oauth2Handler.ClientAuthMiddleware) + clientAuth.Post("/oauth2/introspect", oauth2Handler.IntrospectHandler) + clientAuth.Post("/oauth2/revoke", oauth2Handler.RevokeHandler) + + // Session-authenticated endpoints. + router.Get("/oauth2/authorize", oauth2Handler.AuthorizeHandler) + + requireIdentity := router.With(authn.NewIdentityPresenceMiddleware()) + requireIdentity.Post("/oauth2/register", oauth2Handler.RegisterHandler) + return r } diff --git a/pkg/server/api/connect/v1/types/oauth2.go b/pkg/server/api/connect/v1/types/oauth2.go new file mode 100644 index 000000000..3526d9d10 --- /dev/null +++ b/pkg/server/api/connect/v1/types/oauth2.go @@ -0,0 +1,330 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 types + +import ( + "fmt" + "net/http" + "net/url" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/uri" +) + +func requireGID(values url.Values, param string) (gid.GID, error) { + v := values.Get(param) + if v == "" { + return gid.GID{}, fmt.Errorf("missing %s", param) + } + + id, err := gid.ParseGID(v) + if err != nil { + return gid.GID{}, fmt.Errorf("invalid %s", param) + } + + return id, nil +} + +func parseScopes(s string) (coredata.OAuth2Scopes, error) { + var scopes coredata.OAuth2Scopes + if err := scopes.UnmarshalText([]byte(s)); err != nil { + return nil, err + } + return scopes, nil +} + +type ( + OAuth2AuthorizeInput struct { + ClientID gid.GID + RedirectURI string + State string + ResponseType coredata.OAuth2ResponseType + Scopes coredata.OAuth2Scopes + CodeChallenge string + CodeChallengeMethod coredata.OAuth2CodeChallengeMethod + Nonce string + } + + OAuth2IntrospectInput struct { + Token string + } + + OAuth2RevokeInput struct { + Token string + TokenTypeHint *coredata.OAuth2TokenTypeHint + } + + OAuth2DeviceAuthInput struct { + ClientID gid.GID + Scopes coredata.OAuth2Scopes + } + + OAuth2AuthorizationCodeGrantInput struct { + ClientID string + ClientSecret string + Code string + RedirectURI string + CodeVerifier string + } + + OAuth2RefreshTokenGrantInput struct { + ClientID string + ClientSecret string + RefreshToken string + } + + OAuth2DeviceCodeGrantInput struct { + ClientID gid.GID + DeviceCode string + } + + OAuth2RegisterInput struct { + OrganizationID *gid.GID `json:"organization_id"` + ClientName string `json:"client_name"` + Visibility coredata.OAuth2ClientVisibility `json:"visibility"` + RedirectURIs []uri.URI `json:"redirect_uris"` + GrantTypes []coredata.OAuth2GrantType `json:"grant_types"` + ResponseTypes []coredata.OAuth2ResponseType `json:"response_types"` + TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_method"` + LogoURI *uri.URI `json:"logo_uri"` + ClientURI *uri.URI `json:"client_uri"` + Contacts []string `json:"contacts"` + Scopes coredata.OAuth2Scopes `json:"scopes"` + } +) + +func (in *OAuth2AuthorizeInput) DecodeQuery(q url.Values) error { + var err error + + in.ClientID, err = requireGID(q, "client_id") + if err != nil { + return err + } + + in.RedirectURI = q.Get("redirect_uri") + in.State = q.Get("state") + in.ResponseType = coredata.OAuth2ResponseType(q.Get("response_type")) + in.CodeChallenge = q.Get("code_challenge") + in.CodeChallengeMethod = coredata.OAuth2CodeChallengeMethod(q.Get("code_challenge_method")) + in.Nonce = q.Get("nonce") + + in.Scopes, err = parseScopes(q.Get("scope")) + if err != nil { + return err + } + + return nil +} + +func (in *OAuth2IntrospectInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + in.Token = r.FormValue("token") + if in.Token == "" { + return fmt.Errorf("missing token parameter") + } + return nil +} + +func (in *OAuth2RevokeInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + in.Token = r.FormValue("token") + + if hint := r.FormValue("token_type_hint"); hint != "" { + h := coredata.OAuth2TokenTypeHint(hint) + if h.IsValid() { + in.TokenTypeHint = &h + } + } + + return nil +} + +func (in *OAuth2DeviceAuthInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + var err error + + in.ClientID, err = requireGID(r.Form, "client_id") + if err != nil { + return err + } + + if scopeStr := r.FormValue("scope"); scopeStr != "" { + in.Scopes, err = parseScopes(scopeStr) + if err != nil { + return fmt.Errorf("invalid scope") + } + } + + return nil +} + +func (in *OAuth2AuthorizationCodeGrantInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + in.ClientID = r.FormValue("client_id") + in.ClientSecret = r.FormValue("client_secret") + in.Code = r.FormValue("code") + in.RedirectURI = r.FormValue("redirect_uri") + in.CodeVerifier = r.FormValue("code_verifier") + + if in.Code == "" { + return fmt.Errorf("missing code") + } + + return nil +} + +func (in *OAuth2RefreshTokenGrantInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + in.ClientID = r.FormValue("client_id") + in.ClientSecret = r.FormValue("client_secret") + in.RefreshToken = r.FormValue("refresh_token") + + if in.RefreshToken == "" { + return fmt.Errorf("missing refresh_token") + } + + return nil +} + +func (in *OAuth2DeviceCodeGrantInput) DecodeForm(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("invalid form data") + } + + var err error + + in.ClientID, err = requireGID(r.Form, "client_id") + if err != nil { + return err + } + + in.DeviceCode = r.FormValue("device_code") + if in.DeviceCode == "" { + return fmt.Errorf("missing device_code") + } + + return nil +} + +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"` + } + + OAuth2IntrospectResponse struct { + Active bool `json:"active"` + Scope coredata.OAuth2Scopes `json:"scope,omitempty"` + ClientID gid.GID `json:"client_id,omitempty"` + Sub gid.GID `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 uri.URI `json:"verification_uri"` + VerificationURIComplete uri.URI `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + } + + OAuth2RegisterResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + ClientName string `json:"client_name"` + Visibility coredata.OAuth2ClientVisibility `json:"visibility"` + RedirectURIs []uri.URI `json:"redirect_uris"` + GrantTypes []coredata.OAuth2GrantType `json:"grant_types"` + ResponseTypes []coredata.OAuth2ResponseType `json:"response_types"` + TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_method"` + Scopes coredata.OAuth2Scopes `json:"scopes"` + } + + OAuth2ErrorResponse struct { + Code string `json:"error"` + Description string `json:"error_description,omitempty"` + } +) + +func NewConsent(consent *coredata.OAuth2Consent) *Consent { + scopes := make([]string, len(consent.Scopes)) + for i, s := range consent.Scopes { + scopes[i] = string(s) + } + + return &Consent{ + ID: consent.ID, + Application: &Application{ID: consent.ClientID}, + Scopes: scopes, + } +} + +func NewApplication(client *coredata.OAuth2Client) *Application { + app := &Application{ + ID: client.ID, + Name: client.ClientName, + } + + if client.LogoURI != nil { + s := string(*client.LogoURI) + app.LogoURL = &s + } + + if client.ClientURI != nil { + s := string(*client.ClientURI) + app.URL = &s + } + + return app +} + +func InactiveIntrospectResponse() *OAuth2IntrospectResponse { + return &OAuth2IntrospectResponse{Active: false} +} + +func ActiveIntrospectResponse(token *coredata.OAuth2AccessToken) *OAuth2IntrospectResponse { + return &OAuth2IntrospectResponse{ + Active: true, + Scope: token.Scopes, + ClientID: token.ClientID, + Sub: token.IdentityID, + Exp: token.ExpiresAt.Unix(), + Iat: token.CreatedAt.Unix(), + TokenType: "Bearer", + } +} diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 746e319e6..c95eb0523 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -92,6 +92,7 @@ func NewMux( r.Group(func(r chi.Router) { r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) + r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) r.Use(authn.NewIdentityPresenceMiddleware()) r.Use(dataloader.NewMiddleware(proboSvc, iamSvc)) diff --git a/pkg/server/server.go b/pkg/server/server.go index 83bca2425..51423b38a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -31,6 +31,7 @@ import ( "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/file" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/securecookie" @@ -41,6 +42,7 @@ import ( console_web "go.probo.inc/probo/pkg/server/web" "go.probo.inc/probo/pkg/slack" "go.probo.inc/probo/pkg/trust" + "go.probo.inc/probo/pkg/uri" ) type Config struct { @@ -70,7 +72,9 @@ type Server struct { trustWebServer *trust_web.Server router *chi.Mux extraHeaderFields map[string]string + baseURL string proboService *probo.Service + iamService *iam.Service trustService *trust.Service logger *log.Logger } @@ -119,7 +123,9 @@ func NewServer(cfg Config) (*Server, error) { trustWebServer: trustWebServer, router: router, extraHeaderFields: cfg.ExtraHeaderFields, + baseURL: cfg.BaseURL.String(), proboService: cfg.Probo, + iamService: cfg.IAM, trustService: cfg.Trust, logger: cfg.Logger, } @@ -130,6 +136,11 @@ func NewServer(cfg Config) (*Server, error) { } func (s *Server) setupRoutes(baseURL string) { + // OIDC Discovery 1.0 §4 and RFC 8414 §3 both require the metadata + // document at the issuer root under well-known paths. + s.router.Get("/.well-known/openid-configuration", s.oidcDiscoveryHandler) + s.router.Get("/.well-known/oauth-authorization-server", s.oidcDiscoveryHandler) + s.router.Mount("/api", http.StripPrefix("/api", s.apiServer)) s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler)) @@ -153,6 +164,25 @@ func (s *Server) setExtraHeaders(w http.ResponseWriter) { } } +func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) { + api := s.baseURL + "/api/connect/v1" + + endpoints := oauth2server.Endpoints{ + Authorization: uri.URI(api + "/oauth2/authorize"), + Token: uri.URI(api + "/oauth2/token"), + Userinfo: uri.URI(api + "/oauth2/userinfo"), + JWKS: uri.URI(api + "/oauth2/jwks"), + Registration: uri.URI(api + "/oauth2/register"), + Introspection: uri.URI(api + "/oauth2/introspect"), + Revocation: uri.URI(api + "/oauth2/revoke"), + DeviceAuthorization: uri.URI(api + "/oauth2/device"), + } + + metadata := s.iamService.OAuth2ServerService.Metadata(endpoints) + w.Header().Set("Cache-Control", "public, max-age=3600") + httpserver.RenderJSON(w, http.StatusOK, metadata) +} + func (s *Server) handleCustomDomain404(w http.ResponseWriter, r *http.Request) { httpserver.RenderError(w, http.StatusNotFound, errors.New("not found")) } diff --git a/pkg/uri/uri.go b/pkg/uri/uri.go new file mode 100644 index 000000000..7403f5eab --- /dev/null +++ b/pkg/uri/uri.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 uri + +import ( + "database/sql/driver" + "fmt" + "net/url" +) + +// URI is a validated absolute URI (scheme + host required). +type URI string + +func Parse(raw string) (URI, error) { + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("%q is not a valid URI", raw) + } + + return URI(raw), nil +} + +func (u URI) String() string { return string(u) } + +func (u *URI) UnmarshalText(text []byte) error { + parsed, err := Parse(string(text)) + if err != nil { + return err + } + + *u = parsed + return nil +} + +func (u URI) MarshalText() ([]byte, error) { + return []byte(u), nil +} + +func (u *URI) Scan(value any) error { + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("unsupported type for URI: %T", value) + } + + parsed, err := Parse(s) + if err != nil { + return err + } + + *u = parsed + return nil +} + +func (u URI) Value() (driver.Value, error) { + return u.String(), nil +} diff --git a/pkg/uri/uri_test.go b/pkg/uri/uri_test.go new file mode 100644 index 000000000..824164baf --- /dev/null +++ b/pkg/uri/uri_test.go @@ -0,0 +1,251 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 uri + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want URI + wantErr bool + }{ + { + name: "valid https url", + input: "https://example.com", + want: URI("https://example.com"), + }, + { + name: "valid https url with path", + input: "https://example.com/callback", + want: URI("https://example.com/callback"), + }, + { + name: "valid https url with port", + input: "https://localhost:8080/callback", + want: URI("https://localhost:8080/callback"), + }, + { + name: "valid http url", + input: "http://localhost:3000/auth/callback", + want: URI("http://localhost:3000/auth/callback"), + }, + { + name: "valid url with query", + input: "https://example.com/path?key=value", + want: URI("https://example.com/path?key=value"), + }, + { + name: "valid custom scheme", + input: "myapp://callback", + want: URI("myapp://callback"), + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "no scheme", + input: "example.com/callback", + wantErr: true, + }, + { + name: "no host", + input: "/callback", + wantErr: true, + }, + { + name: "relative path", + input: "callback", + wantErr: true, + }, + { + name: "scheme only", + input: "https://", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + got, err := Parse(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }, + ) + } +} + +func TestURIUnmarshalText(t *testing.T) { + t.Parallel() + + t.Run( + "valid", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.UnmarshalText([]byte("https://example.com/callback")) + require.NoError(t, err) + assert.Equal(t, URI("https://example.com/callback"), u) + }, + ) + + t.Run( + "invalid", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.UnmarshalText([]byte("not-a-url")) + require.Error(t, err) + }, + ) +} + +func TestURIMarshalText(t *testing.T) { + t.Parallel() + + u := URI("https://example.com/callback") + b, err := u.MarshalText() + require.NoError(t, err) + assert.Equal(t, []byte("https://example.com/callback"), b) +} + +func TestURIJSON(t *testing.T) { + t.Parallel() + + t.Run( + "marshal", + func(t *testing.T) { + t.Parallel() + + v := struct { + URL URI `json:"url"` + }{URL: URI("https://example.com")} + + data, err := json.Marshal(v) + require.NoError(t, err) + assert.JSONEq(t, `{"url":"https://example.com"}`, string(data)) + }, + ) + + t.Run( + "unmarshal valid", + func(t *testing.T) { + t.Parallel() + + var v struct { + URL URI `json:"url"` + } + + err := json.Unmarshal([]byte(`{"url":"https://example.com"}`), &v) + require.NoError(t, err) + assert.Equal(t, URI("https://example.com"), v.URL) + }, + ) + + t.Run( + "unmarshal invalid", + func(t *testing.T) { + t.Parallel() + + var v struct { + URL URI `json:"url"` + } + + err := json.Unmarshal([]byte(`{"url":"not-a-url"}`), &v) + require.Error(t, err) + }, + ) +} + +func TestURIScan(t *testing.T) { + t.Parallel() + + t.Run( + "string", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.Scan("https://example.com") + require.NoError(t, err) + assert.Equal(t, URI("https://example.com"), u) + }, + ) + + t.Run( + "bytes", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.Scan([]byte("https://example.com")) + require.NoError(t, err) + assert.Equal(t, URI("https://example.com"), u) + }, + ) + + t.Run( + "invalid value", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.Scan("not-a-url") + require.Error(t, err) + }, + ) + + t.Run( + "unsupported type", + func(t *testing.T) { + t.Parallel() + + var u URI + err := u.Scan(123) + require.Error(t, err) + }, + ) +} + +func TestURIValue(t *testing.T) { + t.Parallel() + + u := URI("https://example.com") + v, err := u.Value() + require.NoError(t, err) + assert.Equal(t, "https://example.com", v) +}