A community based topic aggregation platform built on atproto

refactor(oauth): remove deprecated OAuth implementation

Remove old OAuth/DPoP implementation that was replaced with simpler
JWT-based authentication:
- Removed OAuth handlers (login, callback, logout, metadata, JWKS)
- Removed DPoP proof generation and transport layer
- Removed OAuth client with PAR/PKCE flows
- Removed OAuth session management and repository
- Removed OAuth integration tests

This implementation was too complex for alpha phase and has been
replaced with direct JWT validation against PDS JWKS endpoints.

See docs/PRD_OAUTH.md for the new simplified approach.

-210
internal/api/handlers/oauth/callback.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"log"
-
"net/http"
-
"os"
-
"strings"
-
"time"
-
-
oauthCore "Coves/internal/core/oauth"
-
)
-
-
const (
-
sessionName = "coves_session"
-
sessionDID = "did"
-
)
-
-
// CallbackHandler handles OAuth callback
-
type CallbackHandler struct {
-
sessionStore oauthCore.SessionStore
-
}
-
-
// NewCallbackHandler creates a new callback handler
-
func NewCallbackHandler(sessionStore oauthCore.SessionStore) *CallbackHandler {
-
return &CallbackHandler{
-
sessionStore: sessionStore,
-
}
-
}
-
-
// HandleCallback processes the OAuth callback
-
// GET /oauth/callback?code=...&state=...&iss=...
-
func (h *CallbackHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
-
// Extract query parameters
-
code := r.URL.Query().Get("code")
-
state := r.URL.Query().Get("state")
-
iss := r.URL.Query().Get("iss")
-
errorParam := r.URL.Query().Get("error")
-
errorDesc := r.URL.Query().Get("error_description")
-
-
// Check for authorization errors
-
if errorParam != "" {
-
log.Printf("OAuth error: %s - %s", errorParam, errorDesc)
-
http.Error(w, "Authorization failed", http.StatusBadRequest)
-
return
-
}
-
-
// Validate required parameters
-
if code == "" || state == "" || iss == "" {
-
http.Error(w, "Missing required OAuth parameters", http.StatusBadRequest)
-
return
-
}
-
-
// Retrieve and delete OAuth request atomically to prevent replay attacks
-
oauthReq, err := h.sessionStore.GetAndDeleteRequest(state)
-
if err != nil {
-
log.Printf("Failed to retrieve OAuth request for state %s: %v", state, err)
-
http.Error(w, "Invalid or expired authorization request", http.StatusBadRequest)
-
return
-
}
-
-
// Verify issuer matches
-
if iss != oauthReq.AuthServerIss {
-
log.Printf("Issuer mismatch: expected %s, got %s", oauthReq.AuthServerIss, iss)
-
http.Error(w, "Authorization server mismatch", http.StatusBadRequest)
-
return
-
}
-
-
// Get OAuth client configuration (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
log.Printf("Failed to load OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
log.Printf("Failed to parse OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
-
appviewURL := getAppViewURL()
-
clientID := getClientID(appviewURL)
-
redirectURI := appviewURL + "/oauth/callback"
-
-
// Create OAuth client
-
client := oauth.NewClient(clientID, privateKey, redirectURI)
-
-
// Parse DPoP key from OAuth request
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(oauthReq.DPoPPrivateJWK))
-
if err != nil {
-
log.Printf("Failed to parse DPoP key: %v", err)
-
http.Error(w, "Failed to restore session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Exchange authorization code for tokens
-
tokenResp, err := client.InitialTokenRequest(
-
r.Context(),
-
code,
-
oauthReq.AuthServerIss,
-
oauthReq.PKCEVerifier,
-
oauthReq.DPoPAuthServerNonce,
-
dpopKey,
-
)
-
if err != nil {
-
log.Printf("Failed to exchange code for tokens: %v", err)
-
http.Error(w, "Failed to obtain access tokens", http.StatusInternalServerError)
-
return
-
}
-
-
// Verify token type is DPoP
-
if tokenResp.TokenType != "DPoP" {
-
log.Printf("Expected DPoP token type, got: %s", tokenResp.TokenType)
-
http.Error(w, "Invalid token type", http.StatusInternalServerError)
-
return
-
}
-
-
// Verify subject (DID) matches
-
if tokenResp.Sub != oauthReq.DID {
-
log.Printf("DID mismatch: expected %s, got %s", oauthReq.DID, tokenResp.Sub)
-
http.Error(w, "Identity verification failed", http.StatusBadRequest)
-
return
-
}
-
-
// Calculate token expiration
-
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
-
-
// Serialize DPoP key for storage
-
dpopKeyJSON, err := oauth.JWKToJSON(dpopKey)
-
if err != nil {
-
log.Printf("Failed to serialize DPoP key: %v", err)
-
http.Error(w, "Failed to store session", http.StatusInternalServerError)
-
return
-
}
-
-
// Save OAuth session to database
-
session := &oauthCore.OAuthSession{
-
DID: oauthReq.DID,
-
Handle: oauthReq.Handle,
-
PDSURL: oauthReq.PDSURL,
-
AccessToken: tokenResp.AccessToken,
-
RefreshToken: tokenResp.RefreshToken,
-
DPoPPrivateJWK: string(dpopKeyJSON),
-
DPoPAuthServerNonce: tokenResp.DpopAuthserverNonce,
-
DPoPPDSNonce: "", // Will be populated on first PDS request
-
AuthServerIss: oauthReq.AuthServerIss,
-
ExpiresAt: expiresAt,
-
}
-
-
if saveErr := h.sessionStore.SaveSession(session); saveErr != nil {
-
log.Printf("Failed to save OAuth session: %v", saveErr)
-
http.Error(w, "Failed to save session", http.StatusInternalServerError)
-
return
-
}
-
-
// Note: OAuth request already deleted atomically in GetAndDeleteRequest above
-
-
// Create HTTP session cookie
-
cookieStore := GetCookieStore()
-
httpSession, err := cookieStore.Get(r, sessionName)
-
if err != nil {
-
log.Printf("Failed to get cookie session: %v", err)
-
// Try to create a new session anyway
-
httpSession, err = cookieStore.New(r, sessionName)
-
if err != nil {
-
log.Printf("Failed to create new session: %v", err)
-
http.Error(w, "Failed to create session", http.StatusInternalServerError)
-
return
-
}
-
}
-
-
httpSession.Values[sessionDID] = oauthReq.DID
-
httpSession.Options.MaxAge = SessionMaxAge
-
httpSession.Options.HttpOnly = true
-
httpSession.Options.Secure = !isDevelopment() // HTTPS only in production
-
httpSession.Options.SameSite = http.SameSiteLaxMode
-
-
if err := httpSession.Save(r, w); err != nil {
-
log.Printf("Failed to save HTTP session: %v", err)
-
http.Error(w, "Failed to create session", http.StatusInternalServerError)
-
return
-
}
-
-
// Determine redirect URL
-
returnURL := oauthReq.ReturnURL
-
if returnURL == "" {
-
returnURL = "/"
-
}
-
-
// Redirect user back to application
-
http.Redirect(w, r, returnURL, http.StatusFound)
-
}
-
-
// isDevelopment checks if we're running in development mode
-
func isDevelopment() bool {
-
// Explicitly check for localhost/127.0.0.1 on any port
-
appviewURL := os.Getenv("APPVIEW_PUBLIC_URL")
-
return appviewURL == "" ||
-
strings.HasPrefix(appviewURL, "http://localhost:") ||
-
strings.HasPrefix(appviewURL, "http://localhost/") ||
-
strings.HasPrefix(appviewURL, "http://127.0.0.1:") ||
-
strings.HasPrefix(appviewURL, "http://127.0.0.1/")
-
}
-17
internal/api/handlers/oauth/constants.go
···
-
package oauth
-
-
import "time"
-
-
const (
-
// Session cookie configuration
-
SessionMaxAge = 7 * 24 * 60 * 60 // 7 days in seconds
-
-
// Minimum security requirements
-
MinCookieSecretLength = 32 // bytes
-
)
-
-
// Time-based constants
-
var (
-
TokenRefreshThreshold = 5 * time.Minute
-
SessionDuration = 7 * 24 * time.Hour
-
)
-37
internal/api/handlers/oauth/cookie.go
···
-
package oauth
-
-
import (
-
"fmt"
-
"sync"
-
-
"github.com/gorilla/sessions"
-
)
-
-
var (
-
// Global singleton cookie store
-
cookieStoreInstance *sessions.CookieStore
-
cookieStoreOnce sync.Once
-
cookieStoreErr error
-
)
-
-
// InitCookieStore initializes the global cookie store singleton
-
// Must be called once at application startup before any handlers are created
-
func InitCookieStore(secret string) error {
-
cookieStoreOnce.Do(func() {
-
if len(secret) < MinCookieSecretLength {
-
cookieStoreErr = fmt.Errorf("OAUTH_COOKIE_SECRET must be at least %d bytes for security", MinCookieSecretLength)
-
return
-
}
-
cookieStoreInstance = sessions.NewCookieStore([]byte(secret))
-
})
-
return cookieStoreErr
-
}
-
-
// GetCookieStore returns the global cookie store singleton
-
// Panics if InitCookieStore has not been called successfully
-
func GetCookieStore() *sessions.CookieStore {
-
if cookieStoreInstance == nil {
-
panic("cookie store not initialized - call InitCookieStore first")
-
}
-
return cookieStoreInstance
-
}
-39
internal/api/handlers/oauth/env.go
···
-
package oauth
-
-
import (
-
"encoding/base64"
-
"fmt"
-
"os"
-
"strings"
-
)
-
-
// GetEnvBase64OrPlain retrieves an environment variable that may be base64 encoded.
-
// If the value starts with "base64:", it will be decoded.
-
// Otherwise, it returns the plain value.
-
//
-
// This allows storing sensitive values like JWKs in base64 format to avoid
-
// shell escaping issues and newline handling problems.
-
//
-
// Example usage in .env:
-
//
-
// OAUTH_PRIVATE_JWK={"alg":"ES256",...} (plain JSON)
-
// OAUTH_PRIVATE_JWK=base64:eyJhbGc... (base64 encoded)
-
func GetEnvBase64OrPlain(key string) (string, error) {
-
value := os.Getenv(key)
-
if value == "" {
-
return "", nil
-
}
-
-
// Check if value is base64 encoded
-
if strings.HasPrefix(value, "base64:") {
-
encoded := strings.TrimPrefix(value, "base64:")
-
decoded, err := base64.StdEncoding.DecodeString(encoded)
-
if err != nil {
-
return "", fmt.Errorf("invalid base64 encoding for %s: %w", key, err)
-
}
-
return string(decoded), nil
-
}
-
-
// Return plain value
-
return value, nil
-
}
-131
internal/api/handlers/oauth/env_test.go
···
-
package oauth
-
-
import (
-
"encoding/base64"
-
"os"
-
"testing"
-
)
-
-
func TestGetEnvBase64OrPlain(t *testing.T) {
-
tests := []struct {
-
name string
-
envKey string
-
envValue string
-
want string
-
wantError bool
-
}{
-
{
-
name: "plain JSON value",
-
envKey: "TEST_PLAIN_JSON",
-
envValue: `{"alg":"ES256","kty":"EC"}`,
-
want: `{"alg":"ES256","kty":"EC"}`,
-
wantError: false,
-
},
-
{
-
name: "base64 encoded value",
-
envKey: "TEST_BASE64_JSON",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte(`{"alg":"ES256","kty":"EC"}`)),
-
want: `{"alg":"ES256","kty":"EC"}`,
-
wantError: false,
-
},
-
{
-
name: "empty value",
-
envKey: "TEST_EMPTY",
-
envValue: "",
-
want: "",
-
wantError: false,
-
},
-
{
-
name: "invalid base64",
-
envKey: "TEST_INVALID_BASE64",
-
envValue: "base64:not-valid-base64!!!",
-
want: "",
-
wantError: true,
-
},
-
{
-
name: "plain string with special chars",
-
envKey: "TEST_SPECIAL_CHARS",
-
envValue: "secret-with-dashes_and_underscores",
-
want: "secret-with-dashes_and_underscores",
-
wantError: false,
-
},
-
{
-
name: "base64 encoded hex string",
-
envKey: "TEST_BASE64_HEX",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte("f1132c01b1a625a865c6c455a75ee793")),
-
want: "f1132c01b1a625a865c6c455a75ee793",
-
wantError: false,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment variable
-
if tt.envValue != "" {
-
if err := os.Setenv(tt.envKey, tt.envValue); err != nil {
-
t.Fatalf("Failed to set env var: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv(tt.envKey); err != nil {
-
t.Errorf("Failed to unset env var: %v", err)
-
}
-
}()
-
}
-
-
got, err := GetEnvBase64OrPlain(tt.envKey)
-
-
if (err != nil) != tt.wantError {
-
t.Errorf("GetEnvBase64OrPlain() error = %v, wantError %v", err, tt.wantError)
-
return
-
}
-
-
if got != tt.want {
-
t.Errorf("GetEnvBase64OrPlain() = %v, want %v", got, tt.want)
-
}
-
})
-
}
-
}
-
-
func TestGetEnvBase64OrPlain_RealWorldJWK(t *testing.T) {
-
// Test with a real JWK (the one from .env.dev)
-
realJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-
-
tests := []struct {
-
name string
-
envValue string
-
want string
-
}{
-
{
-
name: "plain JWK",
-
envValue: realJWK,
-
want: realJWK,
-
},
-
{
-
name: "base64 encoded JWK",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte(realJWK)),
-
want: realJWK,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
if err := os.Setenv("TEST_REAL_JWK", tt.envValue); err != nil {
-
t.Fatalf("Failed to set env var: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("TEST_REAL_JWK"); err != nil {
-
t.Errorf("Failed to unset env var: %v", err)
-
}
-
}()
-
-
got, err := GetEnvBase64OrPlain("TEST_REAL_JWK")
-
if err != nil {
-
t.Fatalf("unexpected error: %v", err)
-
}
-
-
if got != tt.want {
-
t.Errorf("GetEnvBase64OrPlain() = %v, want %v", got, tt.want)
-
}
-
})
-
}
-
}
-53
internal/api/handlers/oauth/jwks.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"encoding/json"
-
"log"
-
"net/http"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// HandleJWKS serves the JSON Web Key Set (JWKS) containing the public key
-
// GET /oauth/jwks.json
-
func HandleJWKS(w http.ResponseWriter, r *http.Request) {
-
// Get private key from environment (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
// Parse private key
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
http.Error(w, "Failed to parse private key", http.StatusInternalServerError)
-
return
-
}
-
-
// Get public key
-
publicKey, err := privateKey.PublicKey()
-
if err != nil {
-
http.Error(w, "Failed to get public key", http.StatusInternalServerError)
-
return
-
}
-
-
// Create JWKS
-
jwks := jwk.NewSet()
-
if err := jwks.AddKey(publicKey); err != nil {
-
http.Error(w, "Failed to create JWKS", http.StatusInternalServerError)
-
return
-
}
-
-
// Serve JWKS
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(jwks); err != nil {
-
log.Printf("Failed to encode JWKS response: %v", err)
-
}
-
}
-177
internal/api/handlers/oauth/login.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/oauth"
-
"encoding/json"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
-
oauthCore "Coves/internal/core/oauth"
-
)
-
-
// LoginHandler handles OAuth login flow initiation
-
type LoginHandler struct {
-
identityResolver identity.Resolver
-
sessionStore oauthCore.SessionStore
-
}
-
-
// NewLoginHandler creates a new login handler
-
func NewLoginHandler(identityResolver identity.Resolver, sessionStore oauthCore.SessionStore) *LoginHandler {
-
return &LoginHandler{
-
identityResolver: identityResolver,
-
sessionStore: sessionStore,
-
}
-
}
-
-
// HandleLogin initiates the OAuth login flow
-
// POST /oauth/login
-
// Body: { "handle": "alice.bsky.social" }
-
func (h *LoginHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-
return
-
}
-
-
// Parse request body
-
var req struct {
-
Handle string `json:"handle"`
-
ReturnURL string `json:"returnUrl,omitempty"`
-
}
-
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
// Normalize handle
-
handle := strings.TrimSpace(strings.ToLower(req.Handle))
-
handle = strings.TrimPrefix(handle, "@")
-
-
// Validate handle format
-
if handle == "" || !strings.Contains(handle, ".") {
-
http.Error(w, "Invalid handle format", http.StatusBadRequest)
-
return
-
}
-
-
// Resolve handle to DID and PDS
-
resolved, err := h.identityResolver.Resolve(r.Context(), handle)
-
if err != nil {
-
log.Printf("Failed to resolve handle %s: %v", handle, err)
-
http.Error(w, "Unable to find that account", http.StatusBadRequest)
-
return
-
}
-
-
// Get OAuth client configuration (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
log.Printf("Failed to load OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
log.Printf("Failed to parse OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
-
appviewURL := getAppViewURL()
-
clientID := getClientID(appviewURL)
-
redirectURI := appviewURL + "/oauth/callback"
-
-
// Create OAuth client
-
client := oauth.NewClient(clientID, privateKey, redirectURI)
-
-
// Discover auth server from PDS
-
pdsURL := resolved.PDSURL
-
authServerIss, err := client.ResolvePDSAuthServer(r.Context(), pdsURL)
-
if err != nil {
-
log.Printf("Failed to resolve auth server for PDS %s: %v", pdsURL, err)
-
http.Error(w, "Failed to discover authorization server", http.StatusInternalServerError)
-
return
-
}
-
-
// Fetch auth server metadata
-
authMeta, err := client.FetchAuthServerMetadata(r.Context(), authServerIss)
-
if err != nil {
-
log.Printf("Failed to fetch auth server metadata: %v", err)
-
http.Error(w, "Failed to fetch authorization server metadata", http.StatusInternalServerError)
-
return
-
}
-
-
// Generate DPoP key for this session
-
dpopKey, err := oauth.GenerateDPoPKey()
-
if err != nil {
-
log.Printf("Failed to generate DPoP key: %v", err)
-
http.Error(w, "Failed to generate session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Send PAR request
-
parResp, err := client.SendPARRequest(r.Context(), authMeta, handle, "atproto transition:generic", dpopKey)
-
if err != nil {
-
log.Printf("Failed to send PAR request: %v", err)
-
http.Error(w, "Failed to initiate authorization", http.StatusInternalServerError)
-
return
-
}
-
-
// Serialize DPoP key to JSON
-
dpopKeyJSON, err := oauth.JWKToJSON(dpopKey)
-
if err != nil {
-
log.Printf("Failed to serialize DPoP key: %v", err)
-
http.Error(w, "Failed to store session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Save OAuth request state to database
-
oauthReq := &oauthCore.OAuthRequest{
-
State: parResp.State,
-
DID: resolved.DID,
-
Handle: handle,
-
PDSURL: pdsURL,
-
PKCEVerifier: parResp.PKCEVerifier,
-
DPoPPrivateJWK: string(dpopKeyJSON),
-
DPoPAuthServerNonce: parResp.DpopAuthserverNonce,
-
AuthServerIss: authServerIss,
-
ReturnURL: req.ReturnURL,
-
}
-
-
if saveErr := h.sessionStore.SaveRequest(oauthReq); saveErr != nil {
-
log.Printf("Failed to save OAuth request: %v", saveErr)
-
http.Error(w, "Failed to save authorization state", http.StatusInternalServerError)
-
return
-
}
-
-
// Build authorization URL
-
authURL, err := url.Parse(authMeta.AuthorizationEndpoint)
-
if err != nil {
-
log.Printf("Invalid authorization endpoint: %v", err)
-
http.Error(w, "Invalid authorization endpoint", http.StatusInternalServerError)
-
return
-
}
-
-
query := authURL.Query()
-
query.Set("client_id", clientID)
-
query.Set("request_uri", parResp.RequestURI)
-
authURL.RawQuery = query.Encode()
-
-
// Return authorization URL to client
-
resp := map[string]string{
-
"authorizationUrl": authURL.String(),
-
"state": parResp.State,
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
-
log.Printf("Failed to encode response: %v", err)
-
}
-
}
-90
internal/api/handlers/oauth/logout.go
···
-
package oauth
-
-
import (
-
"log"
-
"net/http"
-
-
oauthCore "Coves/internal/core/oauth"
-
)
-
-
// LogoutHandler handles user logout
-
type LogoutHandler struct {
-
sessionStore oauthCore.SessionStore
-
}
-
-
// NewLogoutHandler creates a new logout handler
-
func NewLogoutHandler(sessionStore oauthCore.SessionStore) *LogoutHandler {
-
return &LogoutHandler{
-
sessionStore: sessionStore,
-
}
-
}
-
-
// HandleLogout logs out the current user
-
// POST /oauth/logout
-
func (h *LogoutHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-
return
-
}
-
-
// Get HTTP session
-
cookieStore := GetCookieStore()
-
httpSession, err := cookieStore.Get(r, sessionName)
-
if err != nil || httpSession.IsNew {
-
// No session to logout
-
http.Redirect(w, r, "/", http.StatusFound)
-
return
-
}
-
-
// Get DID from session
-
did, ok := httpSession.Values[sessionDID].(string)
-
if !ok || did == "" {
-
// No DID in session
-
http.Redirect(w, r, "/", http.StatusFound)
-
return
-
}
-
-
// Delete OAuth session from database
-
if err := h.sessionStore.DeleteSession(did); err != nil {
-
log.Printf("Failed to delete OAuth session for DID %s: %v", did, err)
-
// Continue with logout anyway
-
}
-
-
// Clear HTTP session cookie
-
httpSession.Options.MaxAge = -1 // Delete cookie
-
if err := httpSession.Save(r, w); err != nil {
-
log.Printf("Failed to clear HTTP session: %v", err)
-
}
-
-
// Redirect to home
-
http.Redirect(w, r, "/", http.StatusFound)
-
}
-
-
// GetCurrentUser returns the currently authenticated user's DID
-
// Helper function for other handlers
-
func GetCurrentUser(r *http.Request) (string, error) {
-
cookieStore := GetCookieStore()
-
httpSession, err := cookieStore.Get(r, sessionName)
-
if err != nil || httpSession.IsNew {
-
return "", err
-
}
-
-
did, ok := httpSession.Values[sessionDID].(string)
-
if !ok || did == "" {
-
return "", nil
-
}
-
-
return did, nil
-
}
-
-
// GetCurrentUserOrError returns the current user's DID or sends an error response
-
// Helper function for protected handlers
-
func GetCurrentUserOrError(w http.ResponseWriter, r *http.Request) (string, bool) {
-
did, err := GetCurrentUser(r)
-
if err != nil || did == "" {
-
http.Error(w, "Unauthorized", http.StatusUnauthorized)
-
return "", false
-
}
-
-
return did, true
-
}
-86
internal/api/handlers/oauth/metadata.go
···
-
package oauth
-
-
import (
-
"encoding/json"
-
"net/http"
-
"os"
-
"strings"
-
)
-
-
// ClientMetadata represents OAuth 2.0 client metadata (RFC 7591)
-
// Served at /oauth/client-metadata.json
-
type ClientMetadata struct {
-
ClientID string `json:"client_id"`
-
ClientName string `json:"client_name"`
-
ClientURI string `json:"client_uri"`
-
Scope string `json:"scope"`
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
-
ApplicationType string `json:"application_type"`
-
JwksURI string `json:"jwks_uri,omitempty"`
-
RedirectURIs []string `json:"redirect_uris"`
-
GrantTypes []string `json:"grant_types"`
-
ResponseTypes []string `json:"response_types"`
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
-
}
-
-
// HandleClientMetadata serves the OAuth client metadata
-
// GET /oauth/client-metadata.json
-
func HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
-
appviewURL := getAppViewURL()
-
-
// Determine client ID based on environment
-
clientID := getClientID(appviewURL)
-
jwksURI := ""
-
-
// Only include JWKS URI in production (not for loopback clients)
-
if !strings.HasPrefix(appviewURL, "http://localhost") && !strings.HasPrefix(appviewURL, "http://127.0.0.1") {
-
jwksURI = appviewURL + "/oauth/jwks.json"
-
}
-
-
metadata := ClientMetadata{
-
ClientID: clientID,
-
ClientName: "Coves",
-
ClientURI: appviewURL,
-
RedirectURIs: []string{appviewURL + "/oauth/callback"},
-
GrantTypes: []string{"authorization_code", "refresh_token"},
-
ResponseTypes: []string{"code"},
-
Scope: "atproto transition:generic",
-
TokenEndpointAuthMethod: "private_key_jwt",
-
TokenEndpointAuthSigningAlg: "ES256",
-
DpopBoundAccessTokens: true,
-
ApplicationType: "web",
-
JwksURI: jwksURI,
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(metadata); err != nil {
-
// Log encoding errors but don't return error response (headers already sent)
-
// This follows Go's standard practice for HTTP handlers
-
_ = err
-
}
-
}
-
-
// getAppViewURL returns the public URL of the AppView
-
func getAppViewURL() string {
-
url := os.Getenv("APPVIEW_PUBLIC_URL")
-
if url == "" {
-
// Default to localhost for development
-
url = "http://localhost:8081"
-
}
-
return strings.TrimSuffix(url, "/")
-
}
-
-
// getClientID returns the OAuth client ID based on environment
-
// For localhost development, use loopback client identifier
-
// For production, use HTTPS URL to client metadata
-
func getClientID(appviewURL string) string {
-
// Development: use loopback client (http://localhost?...)
-
if strings.HasPrefix(appviewURL, "http://localhost") || strings.HasPrefix(appviewURL, "http://127.0.0.1") {
-
return "http://localhost?redirect_uri=" + appviewURL + "/oauth/callback&scope=atproto%20transition:generic"
-
}
-
-
// Production: use HTTPS URL to client metadata
-
return appviewURL + "/oauth/client-metadata.json"
-
}
-350
internal/atproto/oauth/client.go
···
-
package oauth
-
-
import (
-
"context"
-
"encoding/json"
-
"fmt"
-
"io"
-
"net/http"
-
"net/url"
-
"strings"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// Client handles atProto OAuth flows (PAR, PKCE, DPoP)
-
type Client struct {
-
clientJWK jwk.Key
-
httpClient *http.Client
-
clientID string
-
redirectURI string
-
}
-
-
// NewClient creates a new OAuth client
-
func NewClient(clientID string, clientJWK jwk.Key, redirectURI string) *Client {
-
return &Client{
-
clientID: clientID,
-
clientJWK: clientJWK,
-
redirectURI: redirectURI,
-
httpClient: &http.Client{
-
Timeout: 30 * time.Second,
-
},
-
}
-
}
-
-
// AuthServerMetadata represents OAuth 2.0 authorization server metadata (RFC 8414)
-
type AuthServerMetadata struct {
-
Issuer string `json:"issuer"`
-
AuthorizationEndpoint string `json:"authorization_endpoint"`
-
TokenEndpoint string `json:"token_endpoint"`
-
PushedAuthReqEndpoint string `json:"pushed_authorization_request_endpoint"`
-
JWKSURI string `json:"jwks_uri"`
-
GrantTypesSupported []string `json:"grant_types_supported"`
-
ResponseTypesSupported []string `json:"response_types_supported"`
-
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
-
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
-
}
-
-
// ResolvePDSAuthServer resolves the authorization server for a PDS
-
// Follows the PDS → Authorization Server discovery flow
-
func (c *Client) ResolvePDSAuthServer(ctx context.Context, pdsURL string) (string, error) {
-
// Fetch PDS metadata from /.well-known/oauth-protected-resource
-
metadataURL := strings.TrimSuffix(pdsURL, "/") + "/.well-known/oauth-protected-resource"
-
-
req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
-
if err != nil {
-
return "", fmt.Errorf("failed to create request: %w", err)
-
}
-
-
resp, err := c.httpClient.Do(req)
-
if err != nil {
-
return "", fmt.Errorf("failed to fetch PDS metadata: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
-
if resp.StatusCode != http.StatusOK {
-
return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
-
}
-
-
var metadata struct {
-
AuthorizationServers []string `json:"authorization_servers"`
-
}
-
-
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
-
return "", fmt.Errorf("failed to decode PDS metadata: %w", err)
-
}
-
-
if len(metadata.AuthorizationServers) == 0 {
-
return "", fmt.Errorf("no authorization servers found for PDS")
-
}
-
-
// Return the first (primary) authorization server
-
return metadata.AuthorizationServers[0], nil
-
}
-
-
// FetchAuthServerMetadata fetches OAuth 2.0 authorization server metadata
-
func (c *Client) FetchAuthServerMetadata(ctx context.Context, issuer string) (*AuthServerMetadata, error) {
-
// OAuth 2.0 discovery endpoint
-
metadataURL := strings.TrimSuffix(issuer, "/") + "/.well-known/oauth-authorization-server"
-
-
req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create request: %w", err)
-
}
-
-
resp, err := c.httpClient.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
-
if resp.StatusCode != http.StatusOK {
-
return nil, fmt.Errorf("auth server returned status %d", resp.StatusCode)
-
}
-
-
var metadata AuthServerMetadata
-
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
-
return nil, fmt.Errorf("failed to decode auth server metadata: %w", err)
-
}
-
-
return &metadata, nil
-
}
-
-
// PARResponse represents the response from a Pushed Authorization Request
-
type PARResponse struct {
-
RequestURI string `json:"request_uri"`
-
State string
-
PKCEVerifier string
-
DpopAuthserverNonce string
-
ExpiresIn int `json:"expires_in"`
-
}
-
-
// SendPARRequest sends a Pushed Authorization Request (PAR) - RFC 9126
-
// This pre-registers the authorization request with the server
-
func (c *Client) SendPARRequest(ctx context.Context, authMeta *AuthServerMetadata, handle, scope string, dpopKey jwk.Key) (*PARResponse, error) {
-
// Generate PKCE challenge
-
pkce, err := GeneratePKCEChallenge()
-
if err != nil {
-
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
-
}
-
-
// Generate state
-
state, err := GenerateState()
-
if err != nil {
-
return nil, fmt.Errorf("failed to generate state: %w", err)
-
}
-
-
// Create form data
-
data := url.Values{}
-
data.Set("client_id", c.clientID)
-
data.Set("redirect_uri", c.redirectURI)
-
data.Set("response_type", "code")
-
data.Set("scope", scope)
-
data.Set("state", state)
-
data.Set("code_challenge", pkce.Challenge)
-
data.Set("code_challenge_method", pkce.Method)
-
data.Set("login_hint", handle) // atProto-specific: suggests which account to use
-
-
// Create DPoP proof for PAR endpoint
-
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, "", "")
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
-
}
-
-
// Send PAR request
-
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
-
if err != nil {
-
return nil, fmt.Errorf("failed to create request: %w", err)
-
}
-
-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
req.Header.Set("DPoP", dpopProof)
-
-
resp, err := c.httpClient.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("failed to send PAR request: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
return nil, fmt.Errorf("failed to read PAR response: %w", err)
-
}
-
-
// Handle DPoP nonce requirement (RFC 9449 Section 8)
-
// If server returns use_dpop_nonce error, retry with the nonce
-
if resp.StatusCode == http.StatusBadRequest {
-
var errorResp struct {
-
Error string `json:"error"`
-
ErrorDescription string `json:"error_description"`
-
}
-
if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error == "use_dpop_nonce" {
-
// Get nonce from response header
-
nonce := resp.Header.Get("DPoP-Nonce")
-
if nonce != "" {
-
// Retry with nonce
-
dpopProof, err = CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, nonce, "")
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof with nonce: %w", err)
-
}
-
-
// Re-create request with new DPoP proof
-
req, err = http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
-
if err != nil {
-
return nil, fmt.Errorf("failed to create retry request: %w", err)
-
}
-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
req.Header.Set("DPoP", dpopProof)
-
-
// Send retry request
-
resp, err = c.httpClient.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("failed to send retry PAR request: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
body, err = io.ReadAll(resp.Body)
-
if err != nil {
-
return nil, fmt.Errorf("failed to read retry PAR response: %w", err)
-
}
-
}
-
}
-
}
-
-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
-
return nil, fmt.Errorf("PAR request failed with status %d", resp.StatusCode)
-
}
-
-
var parResp struct {
-
RequestURI string `json:"request_uri"`
-
ExpiresIn int `json:"expires_in"`
-
}
-
-
if err := json.Unmarshal(body, &parResp); err != nil {
-
return nil, fmt.Errorf("failed to decode PAR response: %w", err)
-
}
-
-
// Extract DPoP nonce from response header (if provided)
-
dpopNonce := resp.Header.Get("DPoP-Nonce")
-
-
return &PARResponse{
-
RequestURI: parResp.RequestURI,
-
ExpiresIn: parResp.ExpiresIn,
-
State: state,
-
PKCEVerifier: pkce.Verifier,
-
DpopAuthserverNonce: dpopNonce,
-
}, nil
-
}
-
-
// TokenResponse represents an OAuth token response
-
type TokenResponse struct {
-
AccessToken string `json:"access_token"`
-
TokenType string `json:"token_type"`
-
RefreshToken string `json:"refresh_token"`
-
Scope string `json:"scope"`
-
Sub string `json:"sub"`
-
DpopAuthserverNonce string
-
ExpiresIn int `json:"expires_in"`
-
}
-
-
// InitialTokenRequest exchanges authorization code for tokens (DPoP-bound)
-
func (c *Client) InitialTokenRequest(ctx context.Context, code, issuer, pkceVerifier, dpopNonce string, dpopKey jwk.Key) (*TokenResponse, error) {
-
// Get auth server metadata for token endpoint
-
authMeta, err := c.FetchAuthServerMetadata(ctx, issuer)
-
if err != nil {
-
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
-
}
-
-
// Create form data
-
data := url.Values{}
-
data.Set("grant_type", "authorization_code")
-
data.Set("code", code)
-
data.Set("redirect_uri", c.redirectURI)
-
data.Set("code_verifier", pkceVerifier)
-
data.Set("client_id", c.clientID)
-
-
// Create DPoP proof for token endpoint
-
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
-
}
-
-
// Send token request
-
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
-
if err != nil {
-
return nil, fmt.Errorf("failed to create request: %w", err)
-
}
-
-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
req.Header.Set("DPoP", dpopProof)
-
-
resp, err := c.httpClient.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("failed to send token request: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
-
var tokenResp TokenResponse
-
if resp.StatusCode != http.StatusOK {
-
return nil, fmt.Errorf("token request failed with status %d", resp.StatusCode)
-
}
-
-
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
-
return nil, fmt.Errorf("failed to decode token response: %w", err)
-
}
-
-
// Extract updated DPoP nonce
-
tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
-
-
return &tokenResp, nil
-
}
-
-
// RefreshTokenRequest refreshes an access token using a refresh token
-
func (c *Client) RefreshTokenRequest(ctx context.Context, refreshToken, issuer, dpopNonce string, dpopKey jwk.Key) (*TokenResponse, error) {
-
// Get auth server metadata for token endpoint
-
authMeta, err := c.FetchAuthServerMetadata(ctx, issuer)
-
if err != nil {
-
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
-
}
-
-
// Create form data
-
data := url.Values{}
-
data.Set("grant_type", "refresh_token")
-
data.Set("refresh_token", refreshToken)
-
data.Set("client_id", c.clientID)
-
-
// Create DPoP proof for token endpoint
-
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
-
}
-
-
// Send refresh request
-
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
-
if err != nil {
-
return nil, fmt.Errorf("failed to create request: %w", err)
-
}
-
-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
req.Header.Set("DPoP", dpopProof)
-
-
resp, err := c.httpClient.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("failed to send refresh request: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
-
-
var tokenResp TokenResponse
-
if resp.StatusCode != http.StatusOK {
-
return nil, fmt.Errorf("refresh request failed with status %d", resp.StatusCode)
-
}
-
-
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
-
return nil, fmt.Errorf("failed to decode token response: %w", err)
-
}
-
-
// Extract updated DPoP nonce
-
tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
-
-
return &tokenResp, nil
-
}
-167
internal/atproto/oauth/dpop.go
···
-
package oauth
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"crypto/sha256"
-
"encoding/base64"
-
"encoding/json"
-
"fmt"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwa"
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
"github.com/lestrrat-go/jwx/v2/jws"
-
"github.com/lestrrat-go/jwx/v2/jwt"
-
)
-
-
// DPoP (Demonstrating Proof of Possession) - RFC 9449
-
// Binds access tokens to specific clients using cryptographic proofs
-
-
// GenerateDPoPKey generates a new ES256 (NIST P-256) keypair for DPoP
-
// Each OAuth session should have its own unique DPoP key
-
func GenerateDPoPKey() (jwk.Key, error) {
-
// Generate ES256 private key
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-
if err != nil {
-
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
-
}
-
-
// Convert to JWK
-
jwkKey, err := jwk.FromRaw(privateKey)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
-
}
-
-
// Set JWK parameters
-
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
-
return nil, fmt.Errorf("failed to set algorithm: %w", err)
-
}
-
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
-
return nil, fmt.Errorf("failed to set key usage: %w", err)
-
}
-
-
return jwkKey, nil
-
}
-
-
// CreateDPoPProof creates a DPoP proof JWT for HTTP requests
-
// Parameters:
-
// - privateKey: The DPoP private key (ES256) as JWK
-
// - method: HTTP method (e.g., "POST", "GET")
-
// - uri: Full HTTP URI (e.g., "https://pds.example.com/xrpc/com.atproto.server.getSession")
-
// - nonce: Optional server-provided nonce (empty on first request, use nonce from 401 response on retry)
-
// - accessToken: Optional access token hash (required when using access token)
-
func CreateDPoPProof(privateKey jwk.Key, method, uri, nonce, accessToken string) (string, error) {
-
// Get public key for JWK thumbprint
-
pubKey, err := privateKey.PublicKey()
-
if err != nil {
-
return "", fmt.Errorf("failed to get public key: %w", err)
-
}
-
-
// Create JWT builder
-
builder := jwt.NewBuilder().
-
Claim("htm", method). // HTTP method
-
Claim("htu", uri). // HTTP URI
-
Claim("iat", time.Now().Unix()). // Issued at
-
Claim("jti", generateJTI()) // Unique JWT ID
-
-
// Add nonce if provided (required after first DPoP request)
-
if nonce != "" {
-
builder = builder.Claim("nonce", nonce)
-
}
-
-
// Add access token hash if provided (required when using access token)
-
if accessToken != "" {
-
ath := hashAccessToken(accessToken)
-
builder = builder.Claim("ath", ath)
-
}
-
-
// Build the token
-
token, err := builder.Build()
-
if err != nil {
-
return "", fmt.Errorf("failed to build JWT: %w", err)
-
}
-
-
// Serialize the token payload to JSON
-
payloadBytes, err := json.Marshal(token)
-
if err != nil {
-
return "", fmt.Errorf("failed to marshal token: %w", err)
-
}
-
-
// Create headers with DPoP-specific fields
-
// RFC 9449 requires the "jwk" header to contain the public key as a JSON object
-
headers := jws.NewHeaders()
-
if setErr := headers.Set(jws.AlgorithmKey, jwa.ES256); setErr != nil {
-
return "", fmt.Errorf("failed to set algorithm: %w", setErr)
-
}
-
if setErr := headers.Set(jws.TypeKey, "dpop+jwt"); setErr != nil {
-
return "", fmt.Errorf("failed to set type: %w", setErr)
-
}
-
// Set the public JWK directly - jwx library will handle serialization
-
if setErr := headers.Set(jws.JWKKey, pubKey); setErr != nil {
-
return "", fmt.Errorf("failed to set JWK: %w", setErr)
-
}
-
-
// Sign using jws.Sign to preserve custom headers
-
// (jwt.Sign() overrides headers, so we use jws.Sign() directly)
-
signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers)))
-
if err != nil {
-
return "", fmt.Errorf("failed to sign JWT: %w", err)
-
}
-
-
return string(signed), nil
-
}
-
-
// generateJTI generates a unique JWT ID for DPoP proofs
-
func generateJTI() string {
-
// Generate 16 random bytes
-
b := make([]byte, 16)
-
if _, err := rand.Read(b); err != nil {
-
// Fallback to timestamp-based ID
-
return fmt.Sprintf("%d", time.Now().UnixNano())
-
}
-
return base64.RawURLEncoding.EncodeToString(b)
-
}
-
-
// hashAccessToken creates the 'ath' (access token hash) claim
-
// ath = base64url(SHA-256(access_token))
-
func hashAccessToken(accessToken string) string {
-
hash := sha256.Sum256([]byte(accessToken))
-
return base64.RawURLEncoding.EncodeToString(hash[:])
-
}
-
-
// ParseJWKFromJSON parses a JWK from JSON bytes
-
func ParseJWKFromJSON(data []byte) (jwk.Key, error) {
-
key, err := jwk.ParseKey(data)
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse JWK: %w", err)
-
}
-
return key, nil
-
}
-
-
// JWKToJSON converts a JWK to JSON bytes
-
func JWKToJSON(key jwk.Key) ([]byte, error) {
-
data, err := json.Marshal(key)
-
if err != nil {
-
return nil, fmt.Errorf("failed to marshal JWK: %w", err)
-
}
-
return data, nil
-
}
-
-
// GetPublicJWKS creates a JWKS (JSON Web Key Set) response for the public key
-
// This is served at /oauth/jwks.json
-
func GetPublicJWKS(privateKey jwk.Key) (jwk.Set, error) {
-
pubKey, err := privateKey.PublicKey()
-
if err != nil {
-
return nil, fmt.Errorf("failed to get public key: %w", err)
-
}
-
-
// Create JWK Set
-
set := jwk.NewSet()
-
if err := set.AddKey(pubKey); err != nil {
-
return nil, fmt.Errorf("failed to add key to set: %w", err)
-
}
-
-
return set, nil
-
}
-172
internal/atproto/oauth/dpop_test.go
···
-
package oauth
-
-
import (
-
"encoding/base64"
-
"encoding/json"
-
"strings"
-
"testing"
-
)
-
-
// TestCreateDPoPProof tests DPoP proof generation and structure
-
func TestCreateDPoPProof(t *testing.T) {
-
// Generate a test DPoP key
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
// Create a DPoP proof
-
proof, err := CreateDPoPProof(dpopKey, "POST", "https://example.com/token", "", "")
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// DPoP proof should be a JWT in form: header.payload.signature
-
parts := strings.Split(proof, ".")
-
if len(parts) != 3 {
-
t.Fatalf("Expected 3 parts in JWT, got %d", len(parts))
-
}
-
-
// Decode and inspect the header
-
headerJSON, decodeErr := base64.RawURLEncoding.DecodeString(parts[0])
-
if decodeErr != nil {
-
t.Fatalf("Failed to decode header: %v", decodeErr)
-
}
-
-
var header map[string]interface{}
-
if unmarshalErr := json.Unmarshal(headerJSON, &header); unmarshalErr != nil {
-
t.Fatalf("Failed to unmarshal header: %v", unmarshalErr)
-
}
-
-
t.Logf("DPoP Header: %s", string(headerJSON))
-
-
// Verify required header fields
-
if header["alg"] != "ES256" {
-
t.Errorf("Expected alg=ES256, got %v", header["alg"])
-
}
-
if header["typ"] != "dpop+jwt" {
-
t.Errorf("Expected typ=dpop+jwt, got %v", header["typ"])
-
}
-
-
// Verify JWK is present and is a JSON object
-
jwkValue, hasJWK := header["jwk"]
-
if !hasJWK {
-
t.Fatal("Header missing 'jwk' field")
-
}
-
-
// JWK should be a map/object, not a string
-
jwkMap, ok := jwkValue.(map[string]interface{})
-
if !ok {
-
t.Fatalf("JWK is not a JSON object, got type: %T, value: %v", jwkValue, jwkValue)
-
}
-
-
// Verify JWK has required fields for EC key
-
if jwkMap["kty"] != "EC" {
-
t.Errorf("Expected kty=EC, got %v", jwkMap["kty"])
-
}
-
if jwkMap["crv"] != "P-256" {
-
t.Errorf("Expected crv=P-256, got %v", jwkMap["crv"])
-
}
-
if _, hasX := jwkMap["x"]; !hasX {
-
t.Error("JWK missing 'x' coordinate")
-
}
-
if _, hasY := jwkMap["y"]; !hasY {
-
t.Error("JWK missing 'y' coordinate")
-
}
-
-
// Verify private key is NOT in the public JWK
-
if _, hasD := jwkMap["d"]; hasD {
-
t.Error("SECURITY: JWK contains private key component 'd'!")
-
}
-
-
// Decode and inspect the payload
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
t.Logf("DPoP Payload: %s", string(payloadJSON))
-
-
// Verify required payload claims
-
if payload["htm"] != "POST" {
-
t.Errorf("Expected htm=POST, got %v", payload["htm"])
-
}
-
if payload["htu"] != "https://example.com/token" {
-
t.Errorf("Expected htu=https://example.com/token, got %v", payload["htu"])
-
}
-
if _, hasIAT := payload["iat"]; !hasIAT {
-
t.Error("Payload missing 'iat' (issued at)")
-
}
-
if _, hasJTI := payload["jti"]; !hasJTI {
-
t.Error("Payload missing 'jti' (JWT ID)")
-
}
-
}
-
-
// TestDPoPProofWithNonce tests DPoP proof with nonce
-
func TestDPoPProofWithNonce(t *testing.T) {
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
testNonce := "test-nonce-12345"
-
proof, err := CreateDPoPProof(dpopKey, "POST", "https://example.com/token", testNonce, "")
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// Decode payload
-
parts := strings.Split(proof, ".")
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
if payload["nonce"] != testNonce {
-
t.Errorf("Expected nonce=%s, got %v", testNonce, payload["nonce"])
-
}
-
}
-
-
// TestDPoPProofWithAccessToken tests DPoP proof with access token hash
-
func TestDPoPProofWithAccessToken(t *testing.T) {
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
testToken := "test-access-token"
-
proof, err := CreateDPoPProof(dpopKey, "GET", "https://example.com/resource", "", testToken)
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// Decode payload
-
parts := strings.Split(proof, ".")
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
ath, hasATH := payload["ath"]
-
if !hasATH {
-
t.Fatal("Payload missing 'ath' (access token hash)")
-
}
-
if ath == "" {
-
t.Error("Access token hash is empty")
-
}
-
-
t.Logf("Access token hash: %v", ath)
-
}
-53
internal/atproto/oauth/pkce.go
···
-
package oauth
-
-
import (
-
"crypto/rand"
-
"crypto/sha256"
-
"encoding/base64"
-
"fmt"
-
)
-
-
// PKCE (Proof Key for Code Exchange) - RFC 7636
-
// Prevents authorization code interception attacks
-
-
// PKCEChallenge contains the code verifier and challenge for PKCE
-
type PKCEChallenge struct {
-
Verifier string // Random string (43-128 characters)
-
Challenge string // Base64URL(SHA256(verifier))
-
Method string // Always "S256" for atProto
-
}
-
-
// GeneratePKCEChallenge generates a new PKCE code verifier and challenge
-
// Uses S256 method (SHA-256 hash) as required by atProto OAuth
-
func GeneratePKCEChallenge() (*PKCEChallenge, error) {
-
// Generate 32 random bytes (will be 43 chars when base64url encoded)
-
verifierBytes := make([]byte, 32)
-
if _, err := rand.Read(verifierBytes); err != nil {
-
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
-
}
-
-
// Base64URL encode (no padding)
-
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
-
-
// Create SHA-256 hash of verifier
-
hash := sha256.Sum256([]byte(verifier))
-
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
-
-
return &PKCEChallenge{
-
Verifier: verifier,
-
Challenge: challenge,
-
Method: "S256",
-
}, nil
-
}
-
-
// GenerateState generates a random state parameter for CSRF protection
-
// State is used to prevent CSRF attacks in the OAuth flow
-
func GenerateState() (string, error) {
-
// Generate 32 random bytes
-
stateBytes := make([]byte, 32)
-
if _, err := rand.Read(stateBytes); err != nil {
-
return "", fmt.Errorf("failed to generate random state: %w", err)
-
}
-
-
return base64.RawURLEncoding.EncodeToString(stateBytes), nil
-
}
-201
internal/atproto/xrpc/dpop_transport.go
···
-
package xrpc
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"fmt"
-
"log"
-
"net/http"
-
"sync"
-
-
oauthCore "Coves/internal/core/oauth"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// DPoPTransport is an http.RoundTripper that automatically adds DPoP proofs to requests
-
// It intercepts HTTP requests and:
-
// 1. Adds Authorization: DPoP <access_token>
-
// 2. Creates and adds DPoP proof JWT
-
// 3. Handles nonce rotation (retries on 401 with new nonce)
-
// 4. Updates nonces in session store
-
type DPoPTransport struct {
-
base http.RoundTripper // Underlying transport (usually http.DefaultTransport)
-
session *oauthCore.OAuthSession // User's OAuth session
-
sessionStore oauthCore.SessionStore // For updating nonces
-
dpopKey jwk.Key // Parsed DPoP private key
-
mu sync.Mutex // Protects nonce updates
-
}
-
-
// NewDPoPTransport creates a new DPoP-enabled HTTP transport
-
func NewDPoPTransport(base http.RoundTripper, session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*DPoPTransport, error) {
-
if base == nil {
-
base = http.DefaultTransport
-
}
-
-
// Parse DPoP private key from session
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(session.DPoPPrivateJWK))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse DPoP key: %w", err)
-
}
-
-
return &DPoPTransport{
-
base: base,
-
session: session,
-
sessionStore: sessionStore,
-
dpopKey: dpopKey,
-
}, nil
-
}
-
-
// RoundTrip implements http.RoundTripper
-
// This is called for every HTTP request made by the client
-
func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-
// Clone the request (don't modify original)
-
req = req.Clone(req.Context())
-
-
// Add Authorization header with DPoP-bound access token
-
req.Header.Set("Authorization", "DPoP "+t.session.AccessToken)
-
-
// Determine which nonce to use based on the target URL
-
nonce := t.getDPoPNonce(req.URL.String())
-
-
// Create DPoP proof for this specific request
-
dpopProof, err := oauth.CreateDPoPProof(
-
t.dpopKey,
-
req.Method,
-
req.URL.String(),
-
nonce,
-
t.session.AccessToken,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
-
}
-
-
// Add DPoP proof header
-
req.Header.Set("DPoP", dpopProof)
-
-
// Execute the request
-
resp, err := t.base.RoundTrip(req)
-
if err != nil {
-
return nil, err
-
}
-
-
// Handle DPoP nonce rotation
-
if resp.StatusCode == http.StatusUnauthorized {
-
// Check if server provided a new nonce
-
newNonce := resp.Header.Get("DPoP-Nonce")
-
if newNonce != "" {
-
// Update nonce and retry request once
-
t.updateDPoPNonce(req.URL.String(), newNonce)
-
-
// Close the 401 response body
-
if err := resp.Body.Close(); err != nil {
-
log.Printf("Failed to close response body: %v", err)
-
}
-
-
// Retry with new nonce
-
return t.retryWithNewNonce(req, newNonce)
-
}
-
}
-
-
// Check for nonce update even on successful responses
-
if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" {
-
t.updateDPoPNonce(req.URL.String(), newNonce)
-
}
-
-
return resp, nil
-
}
-
-
// getDPoPNonce determines which DPoP nonce to use for a given URL
-
func (t *DPoPTransport) getDPoPNonce(url string) string {
-
t.mu.Lock()
-
defer t.mu.Unlock()
-
-
// If URL is to the PDS, use PDS nonce
-
if contains(url, t.session.PDSURL) {
-
return t.session.DPoPPDSNonce
-
}
-
-
// If URL is to auth server, use auth server nonce
-
if contains(url, t.session.AuthServerIss) {
-
return t.session.DPoPAuthServerNonce
-
}
-
-
// Default: no nonce (first request to this server)
-
return ""
-
}
-
-
// updateDPoPNonce updates the appropriate nonce based on URL
-
func (t *DPoPTransport) updateDPoPNonce(url, newNonce string) {
-
t.mu.Lock()
-
-
// Read DID inside lock to avoid race condition
-
did := t.session.DID
-
-
// Update PDS nonce
-
if contains(url, t.session.PDSURL) {
-
t.session.DPoPPDSNonce = newNonce
-
t.mu.Unlock()
-
// Persist to database (async, best-effort)
-
go func() {
-
if err := t.sessionStore.UpdatePDSNonce(did, newNonce); err != nil {
-
log.Printf("Failed to update PDS nonce: %v", err)
-
}
-
}()
-
return
-
}
-
-
// Update auth server nonce
-
if contains(url, t.session.AuthServerIss) {
-
t.session.DPoPAuthServerNonce = newNonce
-
t.mu.Unlock()
-
// Persist to database (async, best-effort)
-
go func() {
-
if err := t.sessionStore.UpdateAuthServerNonce(did, newNonce); err != nil {
-
log.Printf("Failed to update auth server nonce: %v", err)
-
}
-
}()
-
return
-
}
-
-
t.mu.Unlock()
-
}
-
-
// retryWithNewNonce retries a request with an updated DPoP nonce
-
func (t *DPoPTransport) retryWithNewNonce(req *http.Request, newNonce string) (*http.Response, error) {
-
// Create new DPoP proof with updated nonce
-
dpopProof, err := oauth.CreateDPoPProof(
-
t.dpopKey,
-
req.Method,
-
req.URL.String(),
-
newNonce,
-
t.session.AccessToken,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof on retry: %w", err)
-
}
-
-
// Update DPoP header
-
req.Header.Set("DPoP", dpopProof)
-
-
// Retry the request (only once - no infinite loops)
-
return t.base.RoundTrip(req)
-
}
-
-
// contains checks if haystack contains needle (case-sensitive)
-
func contains(haystack, needle string) bool {
-
return len(haystack) >= len(needle) && haystack[:len(needle)] == needle ||
-
len(haystack) > len(needle) && haystack[len(haystack)-len(needle):] == needle
-
}
-
-
// AuthenticatedClient creates an HTTP client with DPoP transport
-
// This is what handlers use to make authenticated requests to the user's PDS
-
func NewAuthenticatedClient(session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*http.Client, error) {
-
transport, err := NewDPoPTransport(nil, session, sessionStore)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP transport: %w", err)
-
}
-
-
return &http.Client{
-
Transport: transport,
-
}, nil
-
}
-91
internal/core/oauth/auth_service.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"context"
-
"fmt"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// AuthService handles authentication-related business logic
-
// Extracted from middleware to maintain clean architecture
-
type AuthService struct {
-
sessionStore SessionStore
-
oauthClient *oauth.Client
-
}
-
-
// NewAuthService creates a new authentication service
-
func NewAuthService(sessionStore SessionStore, oauthClient *oauth.Client) *AuthService {
-
return &AuthService{
-
sessionStore: sessionStore,
-
oauthClient: oauthClient,
-
}
-
}
-
-
// ValidateSession retrieves and validates a user's OAuth session
-
// Returns the session if valid, error if not found or expired
-
func (s *AuthService) ValidateSession(ctx context.Context, did string) (*OAuthSession, error) {
-
session, err := s.sessionStore.GetSession(did)
-
if err != nil {
-
return nil, fmt.Errorf("session not found: %w", err)
-
}
-
return session, nil
-
}
-
-
// RefreshTokenIfNeeded checks if token needs refresh and refreshes if necessary
-
// Returns updated session if refreshed, original session otherwise
-
func (s *AuthService) RefreshTokenIfNeeded(ctx context.Context, session *OAuthSession, threshold time.Duration) (*OAuthSession, error) {
-
// Check if token needs refresh
-
if time.Until(session.ExpiresAt) >= threshold {
-
// Token is still valid, no refresh needed
-
return session, nil
-
}
-
-
// Parse DPoP key
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(session.DPoPPrivateJWK))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse DPoP key: %w", err)
-
}
-
-
// Refresh token
-
tokenResp, err := s.oauthClient.RefreshTokenRequest(
-
ctx,
-
session.RefreshToken,
-
session.AuthServerIss,
-
session.DPoPAuthServerNonce,
-
dpopKey,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to refresh token: %w", err)
-
}
-
-
// Update session with new tokens
-
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
-
if err := s.sessionStore.RefreshSession(session.DID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {
-
return nil, fmt.Errorf("failed to update session: %w", err)
-
}
-
-
// Update nonce if provided (best effort - non-critical)
-
if tokenResp.DpopAuthserverNonce != "" {
-
session.DPoPAuthServerNonce = tokenResp.DpopAuthserverNonce
-
if updateErr := s.sessionStore.UpdateAuthServerNonce(session.DID, tokenResp.DpopAuthserverNonce); updateErr != nil {
-
// Log but don't fail - nonce will be updated on next request
-
// (We ignore the error here intentionally as nonce updates are non-critical)
-
_ = updateErr
-
}
-
}
-
-
// Return updated session
-
session.AccessToken = tokenResp.AccessToken
-
session.RefreshToken = tokenResp.RefreshToken
-
session.ExpiresAt = expiresAt
-
-
return session, nil
-
}
-
-
// CreateDPoPKey generates a new DPoP key for a session
-
func (s *AuthService) CreateDPoPKey() (jwk.Key, error) {
-
return oauth.GenerateDPoPKey()
-
}
-353
internal/core/oauth/repository.go
···
-
package oauth
-
-
import (
-
"context"
-
"database/sql"
-
"fmt"
-
"time"
-
)
-
-
// PostgresSessionStore implements SessionStore using PostgreSQL
-
type PostgresSessionStore struct {
-
db *sql.DB
-
}
-
-
// NewPostgresSessionStore creates a new PostgreSQL-backed session store
-
func NewPostgresSessionStore(db *sql.DB) SessionStore {
-
return &PostgresSessionStore{db: db}
-
}
-
-
// SaveRequest stores a temporary OAuth request state
-
func (s *PostgresSessionStore) SaveRequest(req *OAuthRequest) error {
-
query := `
-
INSERT INTO oauth_requests (
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss, return_url
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
-
`
-
-
_, err := s.db.Exec(
-
query,
-
req.State,
-
req.DID,
-
req.Handle,
-
req.PDSURL,
-
req.PKCEVerifier,
-
req.DPoPPrivateJWK,
-
req.DPoPAuthServerNonce,
-
req.AuthServerIss,
-
req.ReturnURL,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to save OAuth request: %w", err)
-
}
-
-
return nil
-
}
-
-
// GetRequestByState retrieves an OAuth request by state parameter
-
func (s *PostgresSessionStore) GetRequestByState(state string) (*OAuthRequest, error) {
-
query := `
-
SELECT
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
-
COALESCE(return_url, ''), created_at
-
FROM oauth_requests
-
WHERE state = $1
-
`
-
-
var req OAuthRequest
-
err := s.db.QueryRow(query, state).Scan(
-
&req.State,
-
&req.DID,
-
&req.Handle,
-
&req.PDSURL,
-
&req.PKCEVerifier,
-
&req.DPoPPrivateJWK,
-
&req.DPoPAuthServerNonce,
-
&req.AuthServerIss,
-
&req.ReturnURL,
-
&req.CreatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("OAuth request not found for state: %s", state)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get OAuth request: %w", err)
-
}
-
-
return &req, nil
-
}
-
-
// GetAndDeleteRequest atomically retrieves and deletes an OAuth request to prevent replay attacks
-
// This ensures the state parameter can only be used once
-
func (s *PostgresSessionStore) GetAndDeleteRequest(state string) (*OAuthRequest, error) {
-
query := `
-
DELETE FROM oauth_requests
-
WHERE state = $1
-
RETURNING
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
-
COALESCE(return_url, ''), created_at
-
`
-
-
var req OAuthRequest
-
err := s.db.QueryRow(query, state).Scan(
-
&req.State,
-
&req.DID,
-
&req.Handle,
-
&req.PDSURL,
-
&req.PKCEVerifier,
-
&req.DPoPPrivateJWK,
-
&req.DPoPAuthServerNonce,
-
&req.AuthServerIss,
-
&req.ReturnURL,
-
&req.CreatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("OAuth request not found or already used: %s", state)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get and delete OAuth request: %w", err)
-
}
-
-
return &req, nil
-
}
-
-
// DeleteRequest removes an OAuth request (cleanup after callback)
-
func (s *PostgresSessionStore) DeleteRequest(state string) error {
-
query := `DELETE FROM oauth_requests WHERE state = $1`
-
-
_, err := s.db.Exec(query, state)
-
if err != nil {
-
return fmt.Errorf("failed to delete OAuth request: %w", err)
-
}
-
-
return nil
-
}
-
-
// SaveSession stores a new OAuth session (upsert on DID)
-
func (s *PostgresSessionStore) SaveSession(session *OAuthSession) error {
-
query := `
-
INSERT INTO oauth_sessions (
-
did, handle, pds_url, access_token, refresh_token,
-
dpop_private_jwk, dpop_authserver_nonce, dpop_pds_nonce,
-
auth_server_iss, expires_at
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
-
ON CONFLICT (did) DO UPDATE SET
-
handle = EXCLUDED.handle,
-
pds_url = EXCLUDED.pds_url,
-
access_token = EXCLUDED.access_token,
-
refresh_token = EXCLUDED.refresh_token,
-
dpop_private_jwk = EXCLUDED.dpop_private_jwk,
-
dpop_authserver_nonce = EXCLUDED.dpop_authserver_nonce,
-
dpop_pds_nonce = EXCLUDED.dpop_pds_nonce,
-
auth_server_iss = EXCLUDED.auth_server_iss,
-
expires_at = EXCLUDED.expires_at,
-
updated_at = CURRENT_TIMESTAMP
-
`
-
-
_, err := s.db.Exec(
-
query,
-
session.DID,
-
session.Handle,
-
session.PDSURL,
-
session.AccessToken,
-
session.RefreshToken,
-
session.DPoPPrivateJWK,
-
session.DPoPAuthServerNonce,
-
session.DPoPPDSNonce,
-
session.AuthServerIss,
-
session.ExpiresAt,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to save OAuth session: %w", err)
-
}
-
-
return nil
-
}
-
-
// GetSession retrieves an OAuth session by DID
-
func (s *PostgresSessionStore) GetSession(did string) (*OAuthSession, error) {
-
query := `
-
SELECT
-
did, handle, pds_url, access_token, refresh_token,
-
dpop_private_jwk,
-
COALESCE(dpop_authserver_nonce, ''),
-
COALESCE(dpop_pds_nonce, ''),
-
auth_server_iss, expires_at, created_at, updated_at
-
FROM oauth_sessions
-
WHERE did = $1
-
`
-
-
var session OAuthSession
-
err := s.db.QueryRow(query, did).Scan(
-
&session.DID,
-
&session.Handle,
-
&session.PDSURL,
-
&session.AccessToken,
-
&session.RefreshToken,
-
&session.DPoPPrivateJWK,
-
&session.DPoPAuthServerNonce,
-
&session.DPoPPDSNonce,
-
&session.AuthServerIss,
-
&session.ExpiresAt,
-
&session.CreatedAt,
-
&session.UpdatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("session not found for DID: %s", did)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get OAuth session: %w", err)
-
}
-
-
return &session, nil
-
}
-
-
// UpdateSession updates an existing OAuth session
-
func (s *PostgresSessionStore) UpdateSession(session *OAuthSession) error {
-
query := `
-
UPDATE oauth_sessions SET
-
handle = $2,
-
pds_url = $3,
-
access_token = $4,
-
refresh_token = $5,
-
dpop_private_jwk = $6,
-
dpop_authserver_nonce = $7,
-
dpop_pds_nonce = $8,
-
auth_server_iss = $9,
-
expires_at = $10,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
result, err := s.db.Exec(
-
query,
-
session.DID,
-
session.Handle,
-
session.PDSURL,
-
session.AccessToken,
-
session.RefreshToken,
-
session.DPoPPrivateJWK,
-
session.DPoPAuthServerNonce,
-
session.DPoPPDSNonce,
-
session.AuthServerIss,
-
session.ExpiresAt,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to update OAuth session: %w", err)
-
}
-
-
rows, err := result.RowsAffected()
-
if err != nil {
-
return fmt.Errorf("failed to check rows affected: %w", err)
-
}
-
if rows == 0 {
-
return fmt.Errorf("session not found for DID: %s", session.DID)
-
}
-
-
return nil
-
}
-
-
// DeleteSession removes an OAuth session (logout)
-
func (s *PostgresSessionStore) DeleteSession(did string) error {
-
query := `DELETE FROM oauth_sessions WHERE did = $1`
-
-
_, err := s.db.Exec(query, did)
-
if err != nil {
-
return fmt.Errorf("failed to delete OAuth session: %w", err)
-
}
-
-
return nil
-
}
-
-
// RefreshSession updates access and refresh tokens after a token refresh
-
func (s *PostgresSessionStore) RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
-
query := `
-
UPDATE oauth_sessions SET
-
access_token = $2,
-
refresh_token = $3,
-
expires_at = $4,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
result, err := s.db.Exec(query, did, newAccessToken, newRefreshToken, expiresAt)
-
if err != nil {
-
return fmt.Errorf("failed to refresh OAuth session: %w", err)
-
}
-
-
rows, err := result.RowsAffected()
-
if err != nil {
-
return fmt.Errorf("failed to check rows affected: %w", err)
-
}
-
if rows == 0 {
-
return fmt.Errorf("session not found for DID: %s", did)
-
}
-
-
return nil
-
}
-
-
// UpdateAuthServerNonce updates the DPoP nonce for the auth server token endpoint
-
func (s *PostgresSessionStore) UpdateAuthServerNonce(did, nonce string) error {
-
query := `
-
UPDATE oauth_sessions SET
-
dpop_authserver_nonce = $2,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
_, err := s.db.Exec(query, did, nonce)
-
if err != nil {
-
return fmt.Errorf("failed to update auth server nonce: %w", err)
-
}
-
-
return nil
-
}
-
-
// UpdatePDSNonce updates the DPoP nonce for PDS requests
-
func (s *PostgresSessionStore) UpdatePDSNonce(did, nonce string) error {
-
query := `
-
UPDATE oauth_sessions SET
-
dpop_pds_nonce = $2,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
_, err := s.db.Exec(query, did, nonce)
-
if err != nil {
-
return fmt.Errorf("failed to update PDS nonce: %w", err)
-
}
-
-
return nil
-
}
-
-
// CleanupExpiredRequests removes OAuth requests older than 30 minutes
-
// Should be called periodically (e.g., via cron job or background goroutine)
-
func (s *PostgresSessionStore) CleanupExpiredRequests(ctx context.Context) error {
-
query := `DELETE FROM oauth_requests WHERE created_at < NOW() - INTERVAL '30 minutes'`
-
-
_, err := s.db.ExecContext(ctx, query)
-
if err != nil {
-
return fmt.Errorf("failed to cleanup expired requests: %w", err)
-
}
-
-
return nil
-
}
-
-
// CleanupExpiredSessions removes OAuth sessions that have been expired for > 7 days
-
// Gives users time to refresh their tokens before permanent deletion
-
func (s *PostgresSessionStore) CleanupExpiredSessions(ctx context.Context) error {
-
query := `DELETE FROM oauth_sessions WHERE expires_at < NOW() - INTERVAL '7 days'`
-
-
_, err := s.db.ExecContext(ctx, query)
-
if err != nil {
-
return fmt.Errorf("failed to cleanup expired sessions: %w", err)
-
}
-
-
return nil
-
}
-59
internal/core/oauth/session.go
···
-
package oauth
-
-
import (
-
"time"
-
)
-
-
// OAuthRequest represents a temporary OAuth authorization flow state
-
// Stored during the redirect to auth server, deleted after callback
-
type OAuthRequest struct {
-
CreatedAt time.Time `db:"created_at"`
-
State string `db:"state"`
-
DID string `db:"did"`
-
Handle string `db:"handle"`
-
PDSURL string `db:"pds_url"`
-
PKCEVerifier string `db:"pkce_verifier"`
-
DPoPPrivateJWK string `db:"dpop_private_jwk"`
-
DPoPAuthServerNonce string `db:"dpop_authserver_nonce"`
-
AuthServerIss string `db:"auth_server_iss"`
-
ReturnURL string `db:"return_url"`
-
}
-
-
// OAuthSession represents a long-lived authenticated user session
-
// Stored after successful OAuth login, used for all authenticated requests
-
type OAuthSession struct {
-
ExpiresAt time.Time `db:"expires_at"`
-
CreatedAt time.Time `db:"created_at"`
-
UpdatedAt time.Time `db:"updated_at"`
-
DID string `db:"did"`
-
Handle string `db:"handle"`
-
PDSURL string `db:"pds_url"`
-
AccessToken string `db:"access_token"`
-
RefreshToken string `db:"refresh_token"`
-
DPoPPrivateJWK string `db:"dpop_private_jwk"`
-
DPoPAuthServerNonce string `db:"dpop_authserver_nonce"`
-
DPoPPDSNonce string `db:"dpop_pds_nonce"`
-
AuthServerIss string `db:"auth_server_iss"`
-
}
-
-
// SessionStore defines the interface for OAuth session storage
-
type SessionStore interface {
-
// OAuth flow state management
-
SaveRequest(req *OAuthRequest) error
-
GetRequestByState(state string) (*OAuthRequest, error)
-
GetAndDeleteRequest(state string) (*OAuthRequest, error) // Atomic get-and-delete for CSRF protection
-
DeleteRequest(state string) error
-
-
// User session management
-
SaveSession(session *OAuthSession) error
-
GetSession(did string) (*OAuthSession, error)
-
UpdateSession(session *OAuthSession) error
-
DeleteSession(did string) error
-
-
// Token refresh
-
RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error
-
-
// Nonce updates (for DPoP)
-
UpdateAuthServerNonce(did, nonce string) error
-
UpdatePDSNonce(did, nonce string) error
-
}
-452
tests/integration/oauth_test.go
···
-
package integration
-
-
import (
-
"Coves/internal/api/handlers/oauth"
-
"Coves/internal/atproto/identity"
-
"bytes"
-
"context"
-
"encoding/json"
-
"net/http"
-
"net/http/httptest"
-
"os"
-
"testing"
-
-
oauthCore "Coves/internal/core/oauth"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// TestOAuthClientMetadata tests the /oauth/client-metadata.json endpoint
-
func TestOAuthClientMetadata(t *testing.T) {
-
tests := []struct {
-
name string
-
appviewURL string
-
expectedClientID string
-
expectedJWKSURI string
-
expectedRedirect string
-
}{
-
{
-
name: "localhost development",
-
appviewURL: "http://localhost:8081",
-
expectedClientID: "http://localhost?redirect_uri=http://localhost:8081/oauth/callback&scope=atproto%20transition:generic",
-
expectedJWKSURI: "", // No JWKS URI for localhost
-
expectedRedirect: "http://localhost:8081/oauth/callback",
-
},
-
{
-
name: "production HTTPS",
-
appviewURL: "https://coves.social",
-
expectedClientID: "https://coves.social/oauth/client-metadata.json",
-
expectedJWKSURI: "https://coves.social/oauth/jwks.json",
-
expectedRedirect: "https://coves.social/oauth/callback",
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment
-
if err := os.Setenv("APPVIEW_PUBLIC_URL", tt.appviewURL); err != nil {
-
t.Fatalf("Failed to set APPVIEW_PUBLIC_URL: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("APPVIEW_PUBLIC_URL"); err != nil {
-
t.Logf("Failed to unset APPVIEW_PUBLIC_URL: %v", err)
-
}
-
}()
-
-
// Create request
-
req := httptest.NewRequest("GET", "/oauth/client-metadata.json", nil)
-
w := httptest.NewRecorder()
-
-
// Call handler
-
oauth.HandleClientMetadata(w, req)
-
-
// Check status code
-
if w.Code != http.StatusOK {
-
t.Fatalf("expected status 200, got %d", w.Code)
-
}
-
-
// Parse response
-
var metadata oauth.ClientMetadata
-
if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil {
-
t.Fatalf("failed to decode response: %v", err)
-
}
-
-
// Verify client ID
-
if metadata.ClientID != tt.expectedClientID {
-
t.Errorf("expected client_id %q, got %q", tt.expectedClientID, metadata.ClientID)
-
}
-
-
// Verify JWKS URI
-
if metadata.JwksURI != tt.expectedJWKSURI {
-
t.Errorf("expected jwks_uri %q, got %q", tt.expectedJWKSURI, metadata.JwksURI)
-
}
-
-
// Verify redirect URI
-
if len(metadata.RedirectURIs) != 1 || metadata.RedirectURIs[0] != tt.expectedRedirect {
-
t.Errorf("expected redirect_uris [%q], got %v", tt.expectedRedirect, metadata.RedirectURIs)
-
}
-
-
// Verify OAuth spec compliance
-
if metadata.ClientName != "Coves" {
-
t.Errorf("expected client_name 'Coves', got %q", metadata.ClientName)
-
}
-
if metadata.TokenEndpointAuthMethod != "private_key_jwt" {
-
t.Errorf("expected token_endpoint_auth_method 'private_key_jwt', got %q", metadata.TokenEndpointAuthMethod)
-
}
-
if metadata.TokenEndpointAuthSigningAlg != "ES256" {
-
t.Errorf("expected token_endpoint_auth_signing_alg 'ES256', got %q", metadata.TokenEndpointAuthSigningAlg)
-
}
-
if !metadata.DpopBoundAccessTokens {
-
t.Error("expected dpop_bound_access_tokens to be true")
-
}
-
})
-
}
-
}
-
-
// TestOAuthJWKS tests the /oauth/jwks.json endpoint
-
func TestOAuthJWKS(t *testing.T) {
-
// Use the test JWK from .env.dev
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-
-
tests := []struct {
-
name string
-
envValue string
-
expectSuccess bool
-
}{
-
{
-
name: "valid plain JWK",
-
envValue: testJWK,
-
expectSuccess: true,
-
},
-
{
-
name: "missing JWK",
-
envValue: "",
-
expectSuccess: false,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment
-
if tt.envValue != "" {
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envValue); err != nil {
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
-
}
-
}()
-
}
-
-
// Create request
-
req := httptest.NewRequest("GET", "/oauth/jwks.json", nil)
-
w := httptest.NewRecorder()
-
-
// Call handler
-
oauth.HandleJWKS(w, req)
-
-
// Check status code
-
if tt.expectSuccess {
-
if w.Code != http.StatusOK {
-
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
-
}
-
-
// Parse response
-
var jwksResp struct {
-
Keys []map[string]interface{} `json:"keys"`
-
}
-
if err := json.NewDecoder(w.Body).Decode(&jwksResp); err != nil {
-
t.Fatalf("failed to decode JWKS: %v", err)
-
}
-
-
// Verify we got a public key
-
if len(jwksResp.Keys) != 1 {
-
t.Fatalf("expected 1 key, got %d", len(jwksResp.Keys))
-
}
-
-
key := jwksResp.Keys[0]
-
if key["kty"] != "EC" {
-
t.Errorf("expected kty 'EC', got %v", key["kty"])
-
}
-
if key["alg"] != "ES256" {
-
t.Errorf("expected alg 'ES256', got %v", key["alg"])
-
}
-
if key["kid"] != "oauth-client-key" {
-
t.Errorf("expected kid 'oauth-client-key', got %v", key["kid"])
-
}
-
-
// Verify private key is NOT exposed
-
if _, hasPrivate := key["d"]; hasPrivate {
-
t.Error("SECURITY: private key 'd' should not be in JWKS!")
-
}
-
-
} else {
-
if w.Code == http.StatusOK {
-
t.Fatalf("expected error status, got 200")
-
}
-
}
-
})
-
}
-
}
-
-
// TestOAuthLoginHandler tests the OAuth login initiation
-
func TestOAuthLoginHandler(t *testing.T) {
-
// Skip if running in CI without database
-
if os.Getenv("SKIP_INTEGRATION") == "true" {
-
t.Skip("Skipping integration test")
-
}
-
-
// Setup test database
-
db := setupTestDB(t)
-
defer func() {
-
if err := db.Close(); err != nil {
-
t.Logf("Failed to close database: %v", err)
-
}
-
}()
-
-
// Create session store
-
sessionStore := oauthCore.NewPostgresSessionStore(db)
-
-
// Create identity resolver (mock for now - we'll test with real PDS separately)
-
// For now, just test the handler structure and validation
-
-
tests := []struct {
-
name string
-
requestBody map[string]interface{}
-
envJWK string
-
expectedStatus int
-
}{
-
{
-
name: "missing handle",
-
requestBody: map[string]interface{}{
-
"handle": "",
-
},
-
envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`,
-
expectedStatus: http.StatusBadRequest,
-
},
-
{
-
name: "invalid handle format",
-
requestBody: map[string]interface{}{
-
"handle": "no-dots-invalid",
-
},
-
envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`,
-
expectedStatus: http.StatusBadRequest,
-
},
-
{
-
name: "missing OAuth JWK",
-
requestBody: map[string]interface{}{
-
"handle": "alice.bsky.social",
-
},
-
envJWK: "",
-
expectedStatus: http.StatusInternalServerError,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment
-
if tt.envJWK != "" {
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envJWK); err != nil {
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
-
}
-
}()
-
} else {
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
-
}
-
}
-
-
// Create mock identity resolver for validation tests
-
mockResolver := &mockIdentityResolver{}
-
-
// Create handler
-
handler := oauth.NewLoginHandler(mockResolver, sessionStore)
-
-
// Create request
-
bodyBytes, marshalErr := json.Marshal(tt.requestBody)
-
if marshalErr != nil {
-
t.Fatalf("Failed to marshal request body: %v", marshalErr)
-
}
-
req := httptest.NewRequest("POST", "/oauth/login", bytes.NewReader(bodyBytes))
-
req.Header.Set("Content-Type", "application/json")
-
w := httptest.NewRecorder()
-
-
// Call handler
-
handler.HandleLogin(w, req)
-
-
// Check status code
-
if w.Code != tt.expectedStatus {
-
t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String())
-
}
-
})
-
}
-
}
-
-
// TestOAuthCallbackHandler tests the OAuth callback handling
-
func TestOAuthCallbackHandler(t *testing.T) {
-
// Skip if running in CI without database
-
if os.Getenv("SKIP_INTEGRATION") == "true" {
-
t.Skip("Skipping integration test")
-
}
-
-
// Setup test database
-
db := setupTestDB(t)
-
defer func() {
-
if err := db.Close(); err != nil {
-
t.Logf("Failed to close database: %v", err)
-
}
-
}()
-
-
// Create session store
-
sessionStore := oauthCore.NewPostgresSessionStore(db)
-
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-
-
tests := []struct {
-
queryParams map[string]string
-
name string
-
expectedStatus int
-
}{
-
{
-
name: "missing code",
-
queryParams: map[string]string{
-
"state": "test-state",
-
"iss": "https://bsky.social",
-
},
-
expectedStatus: http.StatusBadRequest,
-
},
-
{
-
name: "missing state",
-
queryParams: map[string]string{
-
"code": "test-code",
-
"iss": "https://bsky.social",
-
},
-
expectedStatus: http.StatusBadRequest,
-
},
-
{
-
name: "missing issuer",
-
queryParams: map[string]string{
-
"code": "test-code",
-
"state": "test-state",
-
},
-
expectedStatus: http.StatusBadRequest,
-
},
-
{
-
name: "OAuth error parameter",
-
queryParams: map[string]string{
-
"error": "access_denied",
-
"error_description": "User denied access",
-
},
-
expectedStatus: http.StatusBadRequest,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", testJWK); err != nil {
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
-
}
-
}()
-
-
// Create handler
-
handler := oauth.NewCallbackHandler(sessionStore)
-
-
// Build query string
-
req := httptest.NewRequest("GET", "/oauth/callback", nil)
-
q := req.URL.Query()
-
for k, v := range tt.queryParams {
-
q.Add(k, v)
-
}
-
req.URL.RawQuery = q.Encode()
-
-
w := httptest.NewRecorder()
-
-
// Call handler
-
handler.HandleCallback(w, req)
-
-
// Check status code
-
if w.Code != tt.expectedStatus {
-
t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String())
-
}
-
})
-
}
-
}
-
-
// mockIdentityResolver is a mock for testing
-
type mockIdentityResolver struct{}
-
-
func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) {
-
// Return a mock resolved identity
-
return &identity.Identity{
-
DID: "did:plc:test123",
-
Handle: identifier,
-
PDSURL: "https://test.pds.example",
-
}, nil
-
}
-
-
func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) {
-
return "did:plc:test123", "https://test.pds.example", nil
-
}
-
-
func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) {
-
return &identity.DIDDocument{
-
DID: did,
-
Service: []identity.Service{
-
{
-
ID: "#atproto_pds",
-
Type: "AtprotoPersonalDataServer",
-
ServiceEndpoint: "https://test.pds.example",
-
},
-
},
-
}, nil
-
}
-
-
func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error {
-
return nil
-
}
-
-
// TestJWKParsing tests that we can parse JWKs correctly
-
func TestJWKParsing(t *testing.T) {
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-
-
// Parse the JWK
-
key, err := jwk.ParseKey([]byte(testJWK))
-
if err != nil {
-
t.Fatalf("failed to parse JWK: %v", err)
-
}
-
-
// Verify it's an EC key
-
if key.KeyType() != "EC" {
-
t.Errorf("expected key type 'EC', got %v", key.KeyType())
-
}
-
-
// Verify we can get the public key
-
pubKey, err := key.PublicKey()
-
if err != nil {
-
t.Fatalf("failed to get public key: %v", err)
-
}
-
-
// Verify public key doesn't have private component
-
pubKeyJSON, marshalErr := json.Marshal(pubKey)
-
if marshalErr != nil {
-
t.Fatalf("failed to marshal public key: %v", marshalErr)
-
}
-
var pubKeyMap map[string]interface{}
-
if unmarshalErr := json.Unmarshal(pubKeyJSON, &pubKeyMap); unmarshalErr != nil {
-
t.Fatalf("failed to unmarshal public key: %v", unmarshalErr)
-
}
-
-
if _, hasPrivate := pubKeyMap["d"]; hasPrivate {
-
t.Error("SECURITY: public key should not contain private 'd' component!")
-
}
-
}