Files
probo/e2e/internal/testutil/oauth2.go
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

996 lines
27 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// 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"`
ProtectedResources []string `json:"protected_resources,omitempty"`
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
}
OAuth2ProtectedResourceMetadataResponse struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
BearerMethodsSupported []string `json:"bearer_methods_supported"`
ScopesSupported []string `json:"scopes_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
}
// OAuth2ProtectedResourceMetadata fetches the RFC 9728 protected resource
// metadata document.
func OAuth2ProtectedResourceMetadata(
c *Client,
) (*OAuth2ProtectedResourceMetadataResponse, *OAuth2HTTPResponse, error) {
raw, err := getJSON(c.HTTPClient(), c.BaseURL()+"/.well-known/oauth-protected-resource", nil)
if err != nil {
return nil, nil, err
}
if raw.StatusCode != http.StatusOK {
return nil, raw, nil
}
var result OAuth2ProtectedResourceMetadataResponse
if err := json.Unmarshal(raw.Body, &result); err != nil {
return nil, raw, fmt.Errorf("cannot decode protected resource metadata: %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) {
return OAuth2IntrospectWithHint(c, clientID, clientSecret, token, "")
}
// OAuth2IntrospectWithHint introspects a token with an optional
// token_type_hint per RFC 7662.
func OAuth2IntrospectWithHint(
c *Client,
clientID, clientSecret, token, tokenTypeHint string,
) (*OAuth2IntrospectResponse, *OAuth2HTTPResponse, error) {
values := url.Values{
"token": {token},
}
if tokenTypeHint != "" {
values.Set("token_type_hint", tokenTypeHint)
}
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()
return OAuth2PerformAuthorizationCodeFlowWithScopes(
t,
c,
clientID,
clientSecret,
redirectURI,
"openid email profile offline_access",
)
}
// OAuth2PerformAuthorizationCodeFlowWithScopes performs the authorization code
// flow with the requested OAuth2 scopes.
func OAuth2PerformAuthorizationCodeFlowWithScopes(
t testing.TB,
c *Client,
clientID, clientSecret, redirectURI, scopes string,
) *OAuth2TokenResponse {
t.Helper()
verifier, challenge := GeneratePKCE()
params := url.Values{
"client_id": {clientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {scopes},
"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
}