Add OAuth2/OpenID Connect authorization server
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization server with support for authorization code flow (with PKCE), refresh token rotation, device authorization grant, dynamic client registration, token introspection, and token revocation. Includes database schema, coredata layer, service logic, HTTP handlers, OIDC discovery endpoint, and JWKS publishing. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -15,74 +15,132 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cli/config"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/version"
|
||||
)
|
||||
|
||||
const (
|
||||
hostEU = "eu.console.getprobo.com"
|
||||
hostUS = "us.console.getprobo.com"
|
||||
|
||||
regionEU = "eu"
|
||||
regionUS = "us"
|
||||
regionCustom = "custom"
|
||||
)
|
||||
|
||||
type (
|
||||
oidcDiscovery struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
deviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
tokenErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagHost string
|
||||
flagToken string
|
||||
flagOrganization string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with a Probo host",
|
||||
Example: ` # Interactive login (prompts for hostname, token, and org)
|
||||
Example: ` # Interactive login (select region, opens browser for device authorization)
|
||||
prb auth login
|
||||
|
||||
# Non-interactive login
|
||||
prb auth login --hostname app.getprobo.com --token <token> --org <org-id>`,
|
||||
# Login to Probo EU
|
||||
prb auth login --hostname eu.console.getprobo.com
|
||||
|
||||
# Login to Probo US
|
||||
prb auth login --hostname us.console.getprobo.com
|
||||
|
||||
# Login to a self-hosted instance
|
||||
prb auth login --hostname probo.example.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if f.IOStreams.IsInteractive() {
|
||||
if flagHost == "" {
|
||||
if f.IOStreams.IsInteractive() && flagHost == "" {
|
||||
var region string
|
||||
|
||||
err := huh.NewSelect[string]().
|
||||
Title("Where is your Probo account hosted?").
|
||||
Options(
|
||||
huh.NewOption("Probo EU (eu.console.getprobo.com)", regionEU),
|
||||
huh.NewOption("Probo US (us.console.getprobo.com)", regionUS),
|
||||
huh.NewOption("Other (custom domain)", regionCustom),
|
||||
).
|
||||
Value(®ion).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch region {
|
||||
case regionEU:
|
||||
flagHost = hostEU
|
||||
case regionUS:
|
||||
flagHost = hostUS
|
||||
case regionCustom:
|
||||
err := huh.NewInput().
|
||||
Title("Probo hostname").
|
||||
Placeholder("app.getprobo.com").
|
||||
Placeholder("probo.example.com").
|
||||
Value(&flagHost).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if flagHost == "" {
|
||||
flagHost = "app.getprobo.com"
|
||||
}
|
||||
}
|
||||
|
||||
if flagToken == "" {
|
||||
err := huh.NewInput().
|
||||
Title("API token").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&flagToken).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if flagOrganization == "" {
|
||||
err := huh.NewInput().
|
||||
Title("Default organization ID").
|
||||
Placeholder("optional").
|
||||
Value(&flagOrganization).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("hostname is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flagHost == "" {
|
||||
flagHost = "app.getprobo.com"
|
||||
flagHost = hostEU
|
||||
}
|
||||
|
||||
if flagToken == "" {
|
||||
return fmt.Errorf("token is required; pass --token or run interactively")
|
||||
baseURL := normalizeHostToURL(flagHost)
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Discovering OAuth2 endpoints on %s...\n", flagHost)
|
||||
|
||||
discovery, err := fetchDiscovery(httpClient, baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot discover OAuth2 endpoints: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
@@ -90,9 +148,80 @@ func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceAuth, err := requestDeviceCode(
|
||||
httpClient,
|
||||
discovery.DeviceAuthorizationEndpoint,
|
||||
config.CLIClientID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start device authorization: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.ErrOut,
|
||||
"\nOpen the following URL in your browser and enter the code:\n\n %s\n\n Code: %s\n\n",
|
||||
deviceAuth.VerificationURI,
|
||||
deviceAuth.UserCode,
|
||||
)
|
||||
|
||||
if f.IOStreams.IsInteractive() {
|
||||
openBrowser(deviceAuth.VerificationURIComplete, cfg.Browser)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "Waiting for authorization...")
|
||||
|
||||
token, err := pollForToken(
|
||||
httpClient,
|
||||
discovery.TokenEndpoint,
|
||||
config.CLIClientID,
|
||||
deviceAuth,
|
||||
)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
return fmt.Errorf("cannot complete device authorization: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
if f.IOStreams.IsInteractive() && flagOrganization == "" {
|
||||
_, _ = fmt.Fprintln(f.IOStreams.ErrOut, "Loading organizations...")
|
||||
orgs, orgsErr := fetchOrganizations(baseURL, token.AccessToken)
|
||||
|
||||
if orgsErr == nil && len(orgs) > 0 {
|
||||
selected := orgs[0].ID
|
||||
options := make([]huh.Option[string], 0, len(orgs)+1)
|
||||
for _, org := range orgs {
|
||||
options = append(
|
||||
options,
|
||||
huh.NewOption(
|
||||
fmt.Sprintf("%s (%s)", org.Name, org.ID),
|
||||
org.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
options = append(
|
||||
options,
|
||||
huh.NewOption("Skip (no default)", ""),
|
||||
)
|
||||
|
||||
err = huh.NewSelect[string]().
|
||||
Title("Default organization").
|
||||
Value(&selected).
|
||||
Options(options...).
|
||||
Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flagOrganization = selected
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Hosts[flagHost] = &config.HostConfig{
|
||||
Token: flagToken,
|
||||
Organization: flagOrganization,
|
||||
Token: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
TokenEndpoint: discovery.TokenEndpoint,
|
||||
Organization: flagOrganization,
|
||||
}
|
||||
cfg.ActiveHost = flagHost
|
||||
|
||||
@@ -110,9 +239,275 @@ func NewCmdLogin(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagHost, "hostname", "", "Probo hostname (default: app.getprobo.com)")
|
||||
cmd.Flags().StringVar(&flagToken, "token", "", "API token")
|
||||
cmd.Flags().StringVar(&flagOrganization, "org", "", "Default organization ID")
|
||||
cmd.Flags().StringVar(
|
||||
&flagHost,
|
||||
"hostname",
|
||||
"",
|
||||
"Probo hostname (e.g. eu.console.getprobo.com, us.console.getprobo.com)",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&flagOrganization,
|
||||
"org",
|
||||
"",
|
||||
"Default organization ID",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func normalizeHostToURL(host string) string {
|
||||
lower := strings.ToLower(host)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return strings.TrimRight(host, "/")
|
||||
}
|
||||
return "https://" + strings.TrimRight(host, "/")
|
||||
}
|
||||
|
||||
func fetchDiscovery(client *http.Client, baseURL string) (*oidcDiscovery, error) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
baseURL+"/.well-known/openid-configuration",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch discovery document: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var discovery oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
if discovery.DeviceAuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("server does not support device authorization")
|
||||
}
|
||||
|
||||
if discovery.TokenEndpoint == "" {
|
||||
return nil, fmt.Errorf("server does not advertise a token endpoint")
|
||||
}
|
||||
|
||||
return &discovery, nil
|
||||
}
|
||||
|
||||
func requestDeviceCode(
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
clientID string,
|
||||
) (*deviceAuthResponse, error) {
|
||||
values := url.Values{
|
||||
"client_id": {clientID},
|
||||
"scope": {"openid profile email offline_access"},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
endpoint,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot request device code: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device authorization returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var deviceAuth deviceAuthResponse
|
||||
if err := json.Unmarshal(body, &deviceAuth); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode device authorization response: %w", err)
|
||||
}
|
||||
|
||||
return &deviceAuth, nil
|
||||
}
|
||||
|
||||
func pollForToken(
|
||||
client *http.Client,
|
||||
tokenEndpoint string,
|
||||
clientID string,
|
||||
deviceAuth *deviceAuthResponse,
|
||||
) (*tokenResponse, error) {
|
||||
interval := time.Duration(deviceAuth.Interval) * time.Second
|
||||
if interval < 1*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(deviceAuth.ExpiresIn) * time.Second)
|
||||
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
}
|
||||
|
||||
values := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceAuth.DeviceCode},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
tokenEndpoint,
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot poll token endpoint: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var token tokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
var errResp tokenErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode error response: %w", err)
|
||||
}
|
||||
|
||||
switch errResp.Error {
|
||||
case "authorization_pending":
|
||||
continue
|
||||
case "slow_down":
|
||||
interval += 5 * time.Second
|
||||
continue
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("authorization denied by user")
|
||||
default:
|
||||
return nil, fmt.Errorf("token error: %s: %s", errResp.Error, errResp.ErrorDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewerOrganizationsQuery = `
|
||||
query($first: Int, $filter: ProfileFilter) {
|
||||
viewer {
|
||||
profiles(first: $first, filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type viewerOrganization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func fetchOrganizations(baseURL string, token string) ([]viewerOrganization, error) {
|
||||
client := api.NewClient(
|
||||
baseURL,
|
||||
token,
|
||||
"/api/connect/v1/graphql",
|
||||
config.DefaultHTTPTimeout,
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
"first": 100,
|
||||
"filter": map[string]any{
|
||||
"state": "ACTIVE",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := client.Do(viewerOrganizationsQuery, variables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch organizations: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Viewer struct {
|
||||
Profiles struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
Organization *viewerOrganization `json:"organization"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"profiles"`
|
||||
} `json:"viewer"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse organizations response: %w", err)
|
||||
}
|
||||
|
||||
orgs := make([]viewerOrganization, 0, len(resp.Viewer.Profiles.Edges))
|
||||
for _, edge := range resp.Viewer.Profiles.Edges {
|
||||
if edge.Node.Organization != nil {
|
||||
orgs = append(orgs, *edge.Node.Organization)
|
||||
}
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
func openBrowser(url, browser string) {
|
||||
if browser != "" {
|
||||
_ = exec.Command("sh", "-c", browser+" \"$0\"", url).Start()
|
||||
return
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
_ = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
_ = exec.Command(
|
||||
"rundll32",
|
||||
"url.dll,FileProtocolHandler",
|
||||
url,
|
||||
).Start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,26 @@
|
||||
package logout
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/config"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/version"
|
||||
)
|
||||
|
||||
type oidcDiscovery struct {
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
}
|
||||
|
||||
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
var flagHost string
|
||||
|
||||
@@ -66,10 +77,13 @@ func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := cfg.Hosts[flagHost]; !ok {
|
||||
hc, ok := cfg.Hosts[flagHost]
|
||||
if !ok {
|
||||
return fmt.Errorf("not logged in to %s", flagHost)
|
||||
}
|
||||
|
||||
revokeTokens(flagHost, hc, f)
|
||||
|
||||
delete(cfg.Hosts, flagHost)
|
||||
if cfg.ActiveHost == flagHost {
|
||||
cfg.ActiveHost = ""
|
||||
@@ -93,3 +107,106 @@ func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func revokeTokens(host string, hc *config.HostConfig, f *cmdutil.Factory) {
|
||||
baseURL := normalizeHostToURL(host)
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
discovery, err := fetchRevocationEndpoint(httpClient, baseURL)
|
||||
if err != nil || discovery.RevocationEndpoint == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if hc.RefreshToken != "" {
|
||||
_ = revokeToken(
|
||||
httpClient,
|
||||
discovery.RevocationEndpoint,
|
||||
hc.RefreshToken,
|
||||
"refresh_token",
|
||||
)
|
||||
}
|
||||
|
||||
if hc.Token != "" {
|
||||
_ = revokeToken(
|
||||
httpClient,
|
||||
discovery.RevocationEndpoint,
|
||||
hc.Token,
|
||||
"access_token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRevocationEndpoint(client *http.Client, baseURL string) (*oidcDiscovery, error) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
baseURL+"/.well-known/openid-configuration",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch discovery document: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var discovery oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
return &discovery, nil
|
||||
}
|
||||
|
||||
func revokeToken(
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
token string,
|
||||
tokenTypeHint string,
|
||||
) error {
|
||||
data := url.Values{
|
||||
"token": {token},
|
||||
"token_type_hint": {tokenTypeHint},
|
||||
"client_id": {config.CLIClientID},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
endpoint,
|
||||
strings.NewReader(data.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create revocation request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", version.UserAgent("prb"))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot send revocation request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("revocation endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeHostToURL(host string) string {
|
||||
lower := strings.ToLower(host)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return strings.TrimRight(host, "/")
|
||||
}
|
||||
return "https://" + strings.TrimRight(host, "/")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user