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>
996 lines
27 KiB
Go
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
|
|
}
|