mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
200 lines
6.0 KiB
Go
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)
|
|
}
|