Files

200 lines
6.0 KiB
Go

package oauth
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/google/go-querystring/query"
)
// StartSignupFlow starts an OAuth authorization flow pointed at a specific
// authorization server with prompt=create, asking the server to show its
// signup UI rather than its login UI.
//
// indigo's ClientApp.StartAuthFlow does not expose prompt, so this
// duplicates the PAR + redirect build using only exported indigo helpers.
// The resulting AuthRequestData is persisted via clientApp.Store so the
// existing ServeCallback path handles the return leg unchanged.
//
// authServerHost is the PDS origin (e.g. "https://eurosky.social"). It gets
// resolved to the actual OAuth auth server URL before the PAR call.
func StartSignupFlow(ctx context.Context, clientApp *oauth.ClientApp, authServerHost string) (string, error) {
authserverURL, err := clientApp.Resolver.ResolveAuthServerURL(ctx, authServerHost)
if err != nil {
return "", fmt.Errorf("resolving auth server for %s: %w", authServerHost, err)
}
authserverMeta, err := clientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL)
if err != nil {
return "", fmt.Errorf("fetching auth server metadata: %w", err)
}
state, err := secureRandomBase64(16)
if err != nil {
return "", fmt.Errorf("generating state: %w", err)
}
pkceVerifier, err := secureRandomBase64(48)
if err != nil {
return "", fmt.Errorf("generating PKCE verifier: %w", err)
}
codeChallenge := oauth.S256CodeChallenge(pkceVerifier)
prompt := "create"
body := oauth.PushedAuthRequest{
ClientID: clientApp.Config.ClientID,
State: state,
RedirectURI: clientApp.Config.CallbackURL,
Scope: scopeString(clientApp.Config.Scopes),
ResponseType: "code",
CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256",
Prompt: &prompt,
}
if clientApp.Config.IsConfidential() {
assertionJWT, err := clientApp.Config.NewClientAssertion(authserverMeta.Issuer)
if err != nil {
return "", fmt.Errorf("client assertion: %w", err)
}
body.ClientAssertionType = oauth.ClientAssertionJWTBearer
body.ClientAssertion = assertionJWT
}
vals, err := query.Values(body)
if err != nil {
return "", fmt.Errorf("encoding PAR body: %w", err)
}
bodyBytes := []byte(vals.Encode())
dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256()
if err != nil {
return "", fmt.Errorf("generating DPoP key: %w", err)
}
parURL := authserverMeta.PushedAuthorizationRequestEndpoint
dpopServerNonce := ""
var resp *http.Response
for range 2 {
dpopJWT, err := oauth.NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey)
if err != nil {
return "", fmt.Errorf("DPoP JWT: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return "", fmt.Errorf("new PAR request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("DPoP", dpopJWT)
resp, err = clientApp.Client.Do(req)
if err != nil {
return "", fmt.Errorf("PAR request: %w", err)
}
if n := resp.Header.Get("DPoP-Nonce"); n != "" {
dpopServerNonce = n
}
// Retry once on DPoP nonce challenge
if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" {
reason := readAuthError(resp)
if reason == "use_dpop_nonce" {
continue
}
return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason)
}
break
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, readAuthError(resp))
}
var parResp oauth.PushedAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil {
return "", fmt.Errorf("decoding PAR response: %w", err)
}
info := oauth.AuthRequestData{
State: state,
AuthServerURL: authserverMeta.Issuer,
Scopes: clientApp.Config.Scopes,
PKCEVerifier: pkceVerifier,
RequestURI: parResp.RequestURI,
AuthServerTokenEndpoint: authserverMeta.TokenEndpoint,
AuthServerRevocationEndpoint: authserverMeta.RevocationEndpoint,
DPoPAuthServerNonce: dpopServerNonce,
DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
}
if err := clientApp.Store.SaveAuthRequestInfo(ctx, info); err != nil {
return "", fmt.Errorf("saving auth request info: %w", err)
}
params := url.Values{}
params.Set("client_id", clientApp.Config.ClientID)
params.Set("request_uri", parResp.RequestURI)
redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode())
slog.Debug("started signup flow",
"authserver", authserverMeta.Issuer,
"state", state,
"redirect", redirectURL,
)
return redirectURL, nil
}
// secureRandomBase64 returns `sizeBytes` random bytes base64 (URL-safe, no padding) encoded.
// Mirrors indigo's private helper of the same name.
func secureRandomBase64(sizeBytes int) (string, error) {
buf := make([]byte, sizeBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// scopeString joins OAuth scopes with spaces (OAuth 2.0 / RFC 6749 §3.3).
func scopeString(scopes []string) string {
var out strings.Builder
for i, s := range scopes {
if i > 0 {
out.WriteString(" ")
}
out.WriteString(s)
}
return out.String()
}
// readAuthError best-effort extracts the `error` code from an OAuth error
// response body and always closes the body. Mirrors indigo's private
// parseAuthErrorReason.
func readAuthError(resp *http.Response) string {
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(b, &e) == nil && e.Error != "" {
return e.Error
}
return string(b)
}