package mcp import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/memohai/memoh/internal/db" "github.com/memohai/memoh/internal/db/sqlc" "github.com/memohai/memoh/internal/textutil" ) // OAuthService manages OAuth flows for MCP connections. type OAuthService struct { queries *sqlc.Queries logger *slog.Logger httpClient *http.Client callbackURL string } func NewOAuthService(log *slog.Logger, queries *sqlc.Queries, callbackURL string) *OAuthService { if log == nil { log = slog.Default() } return &OAuthService{ queries: queries, logger: log.With(slog.String("service", "mcp_oauth")), httpClient: &http.Client{Timeout: 15 * time.Second}, callbackURL: callbackURL, } } // DiscoveryResult holds the result of an OAuth discovery flow. type DiscoveryResult struct { ResourceMetadataURL string `json:"resource_metadata_url"` AuthorizationServerURL string `json:"authorization_server_url"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` RegistrationEndpoint string `json:"registration_endpoint,omitempty"` ScopesSupported []string `json:"scopes_supported,omitempty"` ResourceURI string `json:"resource_uri"` } // OAuthStatus describes the current OAuth state of a connection. type OAuthStatus struct { Configured bool `json:"configured"` HasToken bool `json:"has_token"` Expired bool `json:"expired"` Scopes string `json:"scopes,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` AuthServer string `json:"auth_server,omitempty"` CallbackURL string `json:"callback_url"` } // AuthorizeResult holds the authorization URL to redirect the user to. type AuthorizeResult struct { AuthorizationURL string `json:"authorization_url"` } // Discover performs the MCP OAuth discovery flow: // 1. Send request to MCP server, expect 401 with WWW-Authenticate // 2. Fetch Protected Resource Metadata // 3. Fetch Authorization Server Metadata. func (s *OAuthService) Discover(ctx context.Context, serverURL string) (*DiscoveryResult, error) { serverURL = strings.TrimSpace(serverURL) if serverURL == "" { return nil, errors.New("server URL is required") } resourceURI := canonicalResourceURI(serverURL) // Step 1: Probe the MCP server for 401 + WWW-Authenticate resourceMetaURL, challengeScope, err := s.probeForAuth(ctx, serverURL) if err != nil { return nil, fmt.Errorf("oauth probe failed: %w", err) } // Step 2: Fetch Protected Resource Metadata if resourceMetaURL == "" { resourceMetaURL = s.guessResourceMetadataURL(serverURL) } prm, prmErr := s.fetchProtectedResourceMetadata(ctx, resourceMetaURL) var authServerURL string var scopes []string if prmErr == nil && len(prm.AuthorizationServers) > 0 { authServerURL = prm.AuthorizationServers[0] scopes = prm.ScopesSupported } else { // Fallback: some MCP servers (e.g. Linear) don't serve PRM. // Try the server origin directly as the authorization server. s.logger.Info("PRM unavailable, falling back to direct ASM discovery", slog.String("server_url", serverURL), slog.Any("prm_error", prmErr), ) parsed, _ := url.Parse(serverURL) if parsed != nil { authServerURL = parsed.Scheme + "://" + parsed.Host if parsed.Path != "" && parsed.Path != "/" { authServerURL += parsed.Path } } } if authServerURL == "" { if prmErr != nil { return nil, fmt.Errorf("failed to fetch protected resource metadata: %w", prmErr) } return nil, errors.New("no authorization servers found in protected resource metadata") } // Step 3: Fetch Authorization Server Metadata asm, err := s.fetchAuthServerMetadata(ctx, authServerURL) if err != nil { return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) } if len(scopes) == 0 && challengeScope != "" { scopes = strings.Split(challengeScope, " ") } if scopes == nil { scopes = []string{} } return &DiscoveryResult{ ResourceMetadataURL: resourceMetaURL, AuthorizationServerURL: authServerURL, AuthorizationEndpoint: asm.AuthorizationEndpoint, TokenEndpoint: asm.TokenEndpoint, RegistrationEndpoint: asm.RegistrationEndpoint, ScopesSupported: scopes, ResourceURI: resourceURI, }, nil } // SaveDiscovery persists the discovery result for a connection. func (s *OAuthService) SaveDiscovery(ctx context.Context, connectionID string, result *DiscoveryResult) error { connUUID, err := db.ParseUUID(connectionID) if err != nil { return err } _, err = s.queries.UpsertMCPOAuthDiscovery(ctx, sqlc.UpsertMCPOAuthDiscoveryParams{ ConnectionID: connUUID, ResourceMetadataUrl: result.ResourceMetadataURL, AuthorizationServerUrl: result.AuthorizationServerURL, AuthorizationEndpoint: result.AuthorizationEndpoint, TokenEndpoint: result.TokenEndpoint, RegistrationEndpoint: result.RegistrationEndpoint, ScopesSupported: result.ScopesSupported, ResourceUri: result.ResourceURI, }) return err } // StartAuthorization generates PKCE parameters and returns the authorization URL. // Client ID resolution follows MCP spec priority: // 1. User-provided client_id // 2. Previously stored client_id (from prior registration or user input) // 3. Dynamic Client Registration (RFC 7591) if registration_endpoint is available // 4. Error — user must provide a client_id func (s *OAuthService) StartAuthorization(ctx context.Context, connectionID, clientID, clientSecret, callbackURL string) (*AuthorizeResult, error) { if callbackURL == "" { callbackURL = s.callbackURL } connUUID, err := db.ParseUUID(connectionID) if err != nil { return nil, err } token, err := s.queries.GetMCPOAuthToken(ctx, connUUID) if err != nil { return nil, fmt.Errorf("oauth not discovered for this connection: %w", err) } if token.AuthorizationEndpoint == "" { return nil, errors.New("authorization endpoint not configured") } // Resolve client_id via priority chain if clientID == "" { clientID = token.ClientID } if clientSecret == "" { clientSecret = token.ClientSecret } if clientID == "" && token.RegistrationEndpoint != "" { // Attempt Dynamic Client Registration (RFC 7591) regResult, regErr := s.registerClient(ctx, token.RegistrationEndpoint, callbackURL) if regErr != nil { s.logger.Warn("dynamic client registration failed", slog.Any("error", regErr)) } else { clientID = regResult.ClientID dcrSecret := regResult.ClientSecret if err := s.queries.UpdateMCPOAuthPKCEState(ctx, sqlc.UpdateMCPOAuthPKCEStateParams{ ConnectionID: connUUID, PkceCodeVerifier: "", // will be set below StateParam: "", // will be set below ClientID: clientID, RedirectUri: callbackURL, }); err != nil { s.logger.Warn("failed to save DCR client_id", slog.Any("error", err)) } if dcrSecret != "" { clientSecret = dcrSecret _ = s.queries.UpdateMCPOAuthClientSecret(ctx, sqlc.UpdateMCPOAuthClientSecretParams{ ConnectionID: connUUID, ClientSecret: dcrSecret, }) } s.logger.Info("dynamic client registration succeeded", slog.String("client_id", clientID)) } } if clientID == "" { return nil, errors.New("client_id is required: the authorization server does not support automatic registration, please provide a client_id from a registered OAuth application") } // Persist client_secret if provided by the user if clientSecret != "" && clientSecret != token.ClientSecret { _ = s.queries.UpdateMCPOAuthClientSecret(ctx, sqlc.UpdateMCPOAuthClientSecretParams{ ConnectionID: connUUID, ClientSecret: clientSecret, }) } codeVerifier, err := generateCodeVerifier() if err != nil { return nil, fmt.Errorf("failed to generate PKCE code verifier: %w", err) } codeChallenge := computeCodeChallenge(codeVerifier) state, err := generateState() if err != nil { return nil, fmt.Errorf("failed to generate state: %w", err) } if err := s.queries.UpdateMCPOAuthPKCEState(ctx, sqlc.UpdateMCPOAuthPKCEStateParams{ ConnectionID: connUUID, PkceCodeVerifier: codeVerifier, StateParam: state, ClientID: clientID, RedirectUri: callbackURL, }); err != nil { return nil, fmt.Errorf("failed to save PKCE state: %w", err) } params := url.Values{ "response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {callbackURL}, "state": {state}, "code_challenge": {codeChallenge}, "code_challenge_method": {"S256"}, } if token.ResourceUri != "" { params.Set("resource", token.ResourceUri) } scopes := token.ScopesSupported if len(scopes) > 0 { params.Set("scope", strings.Join(scopes, " ")) } authURL := token.AuthorizationEndpoint + "?" + params.Encode() return &AuthorizeResult{AuthorizationURL: authURL}, nil } // HandleCallback exchanges the authorization code for tokens. func (s *OAuthService) HandleCallback(ctx context.Context, state, code string) (string, error) { if state == "" || code == "" { return "", errors.New("state and code are required") } token, err := s.queries.GetMCPOAuthTokenByState(ctx, state) if err != nil { return "", fmt.Errorf("invalid or expired state parameter: %w", err) } if token.TokenEndpoint == "" || token.PkceCodeVerifier == "" { return "", errors.New("invalid OAuth state: missing token endpoint or code verifier") } redirectURI := token.RedirectUri if redirectURI == "" { redirectURI = s.callbackURL } tokenResp, err := s.exchangeCode(ctx, token.TokenEndpoint, code, token.PkceCodeVerifier, token.ClientID, token.ClientSecret, token.ResourceUri, redirectURI) if err != nil { return "", fmt.Errorf("token exchange failed: %w", err) } var expiresAt pgtype.Timestamptz if tokenResp.ExpiresIn > 0 { t := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) expiresAt = pgtype.Timestamptz{Time: t, Valid: true} } if err := s.queries.UpdateMCPOAuthTokens(ctx, sqlc.UpdateMCPOAuthTokensParams{ ConnectionID: token.ConnectionID, AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, TokenType: tokenResp.TokenType, ExpiresAt: expiresAt, Scope: tokenResp.Scope, }); err != nil { return "", fmt.Errorf("failed to save tokens: %w", err) } _ = s.queries.UpdateMCPConnectionAuthType(ctx, sqlc.UpdateMCPConnectionAuthTypeParams{ ID: token.ConnectionID, AuthType: "oauth", }) return token.ConnectionID.String(), nil } // GetValidToken returns a valid access token, refreshing if expired. func (s *OAuthService) GetValidToken(ctx context.Context, connectionID string) (string, error) { connUUID, err := db.ParseUUID(connectionID) if err != nil { return "", err } token, err := s.queries.GetMCPOAuthToken(ctx, connUUID) if err != nil { return "", fmt.Errorf("no oauth token found: %w", err) } if token.AccessToken == "" { return "", errors.New("no access token available, authorization required") } if token.ExpiresAt.Valid && time.Now().After(token.ExpiresAt.Time.Add(-30*time.Second)) { if token.RefreshToken == "" { return "", errors.New("access token expired and no refresh token available") } refreshed, err := s.refreshToken(ctx, token.TokenEndpoint, token.RefreshToken, token.ClientID, token.ResourceUri) if err != nil { return "", fmt.Errorf("token refresh failed: %w", err) } var expiresAt pgtype.Timestamptz if refreshed.ExpiresIn > 0 { t := time.Now().Add(time.Duration(refreshed.ExpiresIn) * time.Second) expiresAt = pgtype.Timestamptz{Time: t, Valid: true} } refreshTokenValue := refreshed.RefreshToken if refreshTokenValue == "" { refreshTokenValue = token.RefreshToken } if err := s.queries.UpdateMCPOAuthTokens(ctx, sqlc.UpdateMCPOAuthTokensParams{ ConnectionID: connUUID, AccessToken: refreshed.AccessToken, RefreshToken: refreshTokenValue, TokenType: refreshed.TokenType, ExpiresAt: expiresAt, Scope: refreshed.Scope, }); err != nil { s.logger.Warn("failed to save refreshed tokens", slog.Any("error", err)) } return refreshed.AccessToken, nil } return token.AccessToken, nil } // GetStatus returns the OAuth status for a connection. func (s *OAuthService) GetStatus(ctx context.Context, connectionID string) (*OAuthStatus, error) { connUUID, err := db.ParseUUID(connectionID) if err != nil { return nil, err } token, err := s.queries.GetMCPOAuthToken(ctx, connUUID) if err != nil { return &OAuthStatus{Configured: false, CallbackURL: s.callbackURL}, nil } status := &OAuthStatus{ Configured: token.AuthorizationEndpoint != "", HasToken: token.AccessToken != "", AuthServer: token.AuthorizationServerUrl, Scopes: token.Scope, CallbackURL: s.callbackURL, } if token.ExpiresAt.Valid { t := db.TimeFromPg(token.ExpiresAt) status.ExpiresAt = &t status.Expired = time.Now().After(token.ExpiresAt.Time) } return status, nil } // RevokeToken clears stored tokens for a connection. func (s *OAuthService) RevokeToken(ctx context.Context, connectionID string) error { connUUID, err := db.ParseUUID(connectionID) if err != nil { return err } return s.queries.ClearMCPOAuthTokens(ctx, connUUID) } // --- internal helpers --- type protectedResourceMetadata struct { AuthorizationServers []string `json:"authorization_servers"` ScopesSupported []string `json:"scopes_supported"` } type authServerMetadata struct { Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` RegistrationEndpoint string `json:"registration_endpoint"` ScopesSupported []string `json:"scopes_supported"` } type tokenResponse struct { AccessToken string `json:"access_token"` //nolint:gosec // intentional: OAuth token response field RefreshToken string `json:"refresh_token"` //nolint:gosec // intentional: OAuth token response field TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` Error string `json:"error,omitempty"` ErrorDescription string `json:"error_description,omitempty"` } func (s *OAuthService) probeForAuth(ctx context.Context, serverURL string) (resourceMetaURL, scope string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, nil) if err != nil { return "", "", err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return "", "", err } defer func() { _ = resp.Body.Close() }() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusUnauthorized { return "", "", fmt.Errorf("expected 401 Unauthorized, got %d (server may not require OAuth)", resp.StatusCode) } wwwAuth := resp.Header.Get("WWW-Authenticate") if wwwAuth == "" { return "", "", nil } resourceMetaURL = extractWWWAuthParam(wwwAuth, "resource_metadata") scope = extractWWWAuthParam(wwwAuth, "scope") return resourceMetaURL, scope, nil } func (*OAuthService) guessResourceMetadataURL(serverURL string) string { parsed, err := url.Parse(serverURL) if err != nil { return "" } base := parsed.Scheme + "://" + parsed.Host if parsed.Path != "" && parsed.Path != "/" { return base + "/.well-known/oauth-protected-resource" + parsed.Path } return base + "/.well-known/oauth-protected-resource" } func (s *OAuthService) fetchProtectedResourceMetadata(ctx context.Context, metadataURL string) (*protectedResourceMetadata, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) if err != nil { return nil, err } resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("resource metadata returned %d: %s", resp.StatusCode, string(body)) } var meta protectedResourceMetadata if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { return nil, err } return &meta, nil } func (s *OAuthService) fetchAuthServerMetadata(ctx context.Context, issuerURL string) (*authServerMetadata, error) { parsed, err := url.Parse(issuerURL) if err != nil { return nil, err } // Try multiple well-known endpoints per MCP spec (RFC 8414 Section 3.1). // For issuer URLs with path components (e.g., https://github.com/login/oauth): // 1. Path appending: https://github.com/login/oauth/.well-known/openid-configuration // 2. Path insertion (OIDC): https://github.com/.well-known/openid-configuration/login/oauth // 3. Path insertion (OAuth): https://github.com/.well-known/oauth-authorization-server/login/oauth base := parsed.Scheme + "://" + parsed.Host var candidates []string if parsed.Path != "" && parsed.Path != "/" { candidates = []string{ base + "/.well-known/oauth-authorization-server" + parsed.Path, base + "/.well-known/oauth-authorization-server", base + "/.well-known/openid-configuration" + parsed.Path, base + "/.well-known/openid-configuration", strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration", } } else { candidates = []string{ base + "/.well-known/oauth-authorization-server", base + "/.well-known/openid-configuration", } } var lastErr error for _, candidate := range candidates { meta, err := s.tryFetchASMetadata(ctx, candidate) if err == nil { return meta, nil } lastErr = err } return nil, fmt.Errorf("could not fetch authorization server metadata: %w", lastErr) } func (s *OAuthService) tryFetchASMetadata(ctx context.Context, metadataURL string) (*authServerMetadata, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) if err != nil { return nil, err } resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("metadata endpoint %s returned %d", metadataURL, resp.StatusCode) } var meta authServerMetadata if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { return nil, err } if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { return nil, errors.New("metadata missing required endpoints") } return &meta, nil } func (s *OAuthService) exchangeCode(ctx context.Context, tokenEndpoint, code, codeVerifier, clientID, clientSecret, resourceURI, redirectURI string) (*tokenResponse, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "client_id": {clientID}, "code_verifier": {codeVerifier}, } if clientSecret != "" { data.Set("client_secret", clientSecret) } if resourceURI != "" { data.Set("resource", resourceURI) } s.logger.Info("exchangeCode request", slog.String("token_endpoint", tokenEndpoint), slog.String("redirect_uri", redirectURI), slog.String("client_id", clientID), slog.Bool("has_secret", clientSecret != ""), slog.Bool("has_verifier", codeVerifier != ""), slog.String("resource_uri", resourceURI), ) req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token exchange returned %d: %s", resp.StatusCode, string(body)) } tok, err := parseTokenResponse(body) if err != nil { return nil, fmt.Errorf("failed to parse token response: %w (body: %s)", err, truncate(string(body), 256)) } return tok, nil } // parseTokenResponse tries JSON first, then falls back to form-encoded // (GitHub's token endpoint returns form-encoded by default). func parseTokenResponse(body []byte) (*tokenResponse, error) { var tok tokenResponse if err := json.Unmarshal(body, &tok); err == nil { if tok.Error != "" { if tok.ErrorDescription != "" { return nil, fmt.Errorf("%s: %s", tok.Error, tok.ErrorDescription) } return nil, fmt.Errorf("%s", tok.Error) } if tok.AccessToken == "" { return nil, errors.New("no access_token in response") } if tok.TokenType == "" { tok.TokenType = "Bearer" } return &tok, nil } vals, err := url.ParseQuery(string(body)) if err != nil { return nil, fmt.Errorf("response is neither JSON nor form-encoded: %w", err) } if errCode := vals.Get("error"); errCode != "" { desc := vals.Get("error_description") if desc != "" { return nil, fmt.Errorf("%s: %s", errCode, desc) } return nil, fmt.Errorf("%s", errCode) } tok.AccessToken = vals.Get("access_token") tok.RefreshToken = vals.Get("refresh_token") tok.TokenType = vals.Get("token_type") tok.Scope = vals.Get("scope") if tok.TokenType == "" { tok.TokenType = "Bearer" } if tok.AccessToken == "" { return nil, errors.New("no access_token in response") } return &tok, nil } func truncate(s string, maxLen int) string { return textutil.TruncateRunesWithSuffix(s, maxLen, "...") } func (s *OAuthService) refreshToken(ctx context.Context, tokenEndpoint, refreshToken, clientID, resourceURI string) (*tokenResponse, error) { data := url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {clientID}, } if resourceURI != "" { data.Set("resource", resourceURI) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, fmt.Errorf("failed to read refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token refresh returned %d: %s", resp.StatusCode, string(body)) } tok, err := parseTokenResponse(body) if err != nil { return nil, fmt.Errorf("failed to parse refresh response: %w", err) } return tok, nil } // --- Dynamic Client Registration (RFC 7591) --- type dcrRequest struct { ClientName string `json:"client_name"` RedirectURIs []string `json:"redirect_uris"` GrantTypes []string `json:"grant_types"` ResponseTypes []string `json:"response_types"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` } type dcrResponse struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec // intentional: OAuth Dynamic Client Registration response field } func (s *OAuthService) registerClient(ctx context.Context, registrationEndpoint, callbackURL string) (*dcrResponse, error) { body := dcrRequest{ ClientName: "Memoh", RedirectURIs: []string{callbackURL}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: "none", } bodyJSON, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationEndpoint, strings.NewReader(string(bodyJSON))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) //nolint:gosec // G704: URL is from OAuth server discovery metadata or operator config, not user input if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("DCR returned %d: %s", resp.StatusCode, string(respBody)) } var result dcrResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode DCR response: %w", err) } if result.ClientID == "" { return nil, errors.New("DCR response missing client_id") } return &result, nil } // --- PKCE helpers --- func generateCodeVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func computeCodeChallenge(verifier string) string { h := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h[:]) } func generateState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func canonicalResourceURI(serverURL string) string { parsed, err := url.Parse(serverURL) if err != nil { return serverURL } result := strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) if parsed.Path != "" && parsed.Path != "/" { result += strings.TrimRight(parsed.Path, "/") } return result } func extractWWWAuthParam(header, param string) string { lower := strings.ToLower(header) key := strings.ToLower(param) + "=" idx := strings.Index(lower, key) if idx < 0 { return "" } rest := header[idx+len(key):] if len(rest) > 0 && rest[0] == '"' { end := strings.Index(rest[1:], "\"") if end >= 0 { return rest[1 : end+1] } return rest[1:] } end := strings.IndexAny(rest, " ,") if end >= 0 { return rest[:end] } return rest }