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) +}