Files
at-container-registry/pkg/hold/pds/auth.go
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five
behavioral branches: the registry's fall-back to the default hold when
the user's hold is unreachable, backfill warning suppression for
external holds, the appview listener close on shutdown, the hold's
relay-crawl skip, and the hold's appview-issuer tolerance. Every one of
them is a "this is a local development build" decision, which is what
the tag already says, and local development has to build with the tag
or nothing resolves. So they read atproto.TestModeBuild now, and the
flag, SetTestMode, IsTestMode, the middleware option, the backfill
constructor parameter, the never-read field on RemoteHoldAuthorizer,
the example and template YAML lines, and the docker-compose env vars
are gone. The registry keeps the fallback as a field seeded from the
constant so the production-path tests can pin it off under the tag.

The 24 SetTestMode calls in tests were dead already: stripping them and
running the affected packages tagged changed nothing.

Tests that resolve a loopback did:web used to t.Fatal naming the tag,
which left a bare `go test ./...` permanently red in five packages.
They now live under `//go:build testmode`: whole-file constraints where
every test needs it, and sibling *_testmode_test.go files holding the
moved tests plus their fixtures where a file mixed. The harness carries
the constraint too, with its package doc in an untagged doc.go so the
package still exists without it. An untagged run compiles those tests
out and passes; make test keeps the tag and runs everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 11:09:44 -05:00

799 lines
27 KiB
Go

package pds
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/golang-jwt/jwt/v5"
)
// Authentication errors
var (
ErrMissingAuthHeader = errors.New("missing Authorization header")
ErrInvalidAuthFormat = errors.New("invalid Authorization header format")
ErrInvalidAuthScheme = errors.New("invalid authorization scheme: expected 'Bearer' or 'DPoP'")
ErrMissingToken = errors.New("missing token")
ErrMissingDPoPHeader = errors.New("missing DPoP header")
)
// JWT validation errors
var (
ErrInvalidJWTFormat = errors.New("invalid JWT format: expected header.payload.signature")
ErrMissingISSClaim = errors.New("missing 'iss' claim in token")
ErrMissingSubClaim = errors.New("missing 'sub' claim in token")
ErrTokenExpired = errors.New("token has expired")
ErrTokenReplayed = errors.New("token jti already seen (replay)")
)
// sharedAppviewJTICache deduplicates appview JTIs across all
// ValidateAppviewToken calls in this process. A per-process cache is enough
// because every appview-issued token names a single hold as its audience.
var sharedAppviewJTICache = newJTIReplayCache()
// AuthError provides structured authorization error information
type AuthError struct {
Action string // The action being attempted: "blob:read", "blob:write", "crew:admin"
Reason string // Why access was denied
Required []string // What permission(s) would grant access
}
func (e *AuthError) Error() string {
return fmt.Sprintf("access denied for %s: %s (required: %s)",
e.Action, e.Reason, strings.Join(e.Required, " or "))
}
// NewAuthError creates a new AuthError
func NewAuthError(action, reason string, required ...string) *AuthError {
return &AuthError{
Action: action,
Reason: reason,
Required: required,
}
}
// HTTPClient interface allows injecting a custom HTTP client for testing
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
// ValidatedUser represents a successfully validated user from DPoP + OAuth
type ValidatedUser struct {
DID string
Handle string
PDS string
Authorized bool
}
// ValidateDPoPRequest validates a request with DPoP + OAuth tokens
// This implements the standard ATProto token validation flow:
// 1. Extract Authorization header (DPoP <token>)
// 2. Extract DPoP header (proof JWT)
// 3. Call user's PDS to validate token via com.atproto.server.getSession
// 4. Return validated user DID
//
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
// This allows tests to inject a mock HTTP client.
func ValidateDPoPRequest(r *http.Request, httpClient HTTPClient) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, ErrMissingAuthHeader
}
// Check for DPoP authorization scheme
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return nil, ErrInvalidAuthFormat
}
if parts[0] != "DPoP" {
return nil, fmt.Errorf("expected DPoP authorization scheme, got: %s", parts[0])
}
accessToken := parts[1]
if accessToken == "" {
return nil, ErrMissingToken
}
// Extract DPoP header
dpopProof := r.Header.Get("DPoP")
if dpopProof == "" {
return nil, ErrMissingDPoPHeader
}
// TODO: We could verify the DPoP proof locally (signature, HTM, HTU, etc.)
// For now, we'll rely on the PDS to validate everything
// The token contains the user's DID in its claims, but we can't trust it without validation
// We need to call the user's PDS to validate the token
// Problem: We don't know which PDS to call yet!
// For now, we'll parse the JWT to extract the DID/PDS hint (unverified)
// Then validate against that PDS
// This is safe because the PDS will verify the token is valid for that DID
did, pds, err := extractDIDFromToken(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to extract DID from token: %w", err)
}
// Validate token with the user's PDS
session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof, httpClient)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
// Verify the DID matches
if session.DID != did {
return nil, fmt.Errorf("token DID mismatch: expected %s, got %s", did, session.DID)
}
return &ValidatedUser{
DID: session.DID,
Handle: session.Handle,
PDS: pds,
Authorized: true,
}, nil
}
// extractDIDFromToken extracts the DID and PDS from an unverified JWT token
// This is just for routing purposes - the token will be validated by the PDS
func extractDIDFromToken(token string) (string, string, error) {
// JWT format: header.payload.signature
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", "", ErrInvalidJWTFormat
}
// Decode payload (base64url)
payload, err := decodeBase64URL(parts[1])
if err != nil {
return "", "", fmt.Errorf("failed to decode payload: %w", err)
}
// Parse JSON
var claims struct {
Sub string `json:"sub"` // DID
Iss string `json:"iss"` // PDS URL (issuer)
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", "", fmt.Errorf("failed to parse claims: %w", err)
}
if claims.Sub == "" {
return "", "", ErrMissingSubClaim
}
if claims.Iss == "" {
return "", "", ErrMissingISSClaim
}
return claims.Sub, claims.Iss, nil
}
// decodeBase64URL decodes base64url (RFC 4648)
func decodeBase64URL(s string) ([]byte, error) {
// Use Go's RawURLEncoding (base64url without padding)
return base64.RawURLEncoding.DecodeString(s)
}
// SessionResponse represents the response from com.atproto.server.getSession
type SessionResponse struct {
DID string `json:"did"`
Handle string `json:"handle"`
}
// validateTokenWithPDS calls the user's PDS to validate the token
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string, httpClient HTTPClient) (*SessionResponse, error) {
// Call com.atproto.server.getSession with DPoP headers
url := fmt.Sprintf("%s%s", strings.TrimSuffix(pdsURL, "/"), atproto.ServerGetSession)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add DPoP authorization headers
req.Header.Set("Authorization", "DPoP "+accessToken)
req.Header.Set("DPoP", dpopProof)
// Use provided client or default to http.DefaultClient
client := httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call PDS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body))
}
var session SessionResponse
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
return &session, nil
}
// ValidateOwnerOrCrewAdmin validates that the request has valid authentication
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission.
// Supports two authentication methods:
// 1. Service tokens (Bearer tokens from com.atproto.server.getServiceAuth) - for AppView access
// 2. DPoP + OAuth tokens - for direct user access
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// Try service token validation first (for AppView access)
authHeader := r.Header.Get("Authorization")
var user *ValidatedUser
var err error
if strings.HasPrefix(authHeader, "Bearer ") {
// Service token authentication
user, err = ValidateServiceToken(r, pds.did, httpClient)
if err != nil {
return nil, fmt.Errorf("service token authentication failed: %w", err)
}
} else if strings.HasPrefix(authHeader, "DPoP ") {
// DPoP + OAuth authentication (direct user access)
user, err = ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
}
} else {
return nil, ErrInvalidAuthScheme
}
// Get captain record to check owner
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// Check if user is the owner
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with admin permission
member, isCrew, err := pds.lookupCrewMember(r.Context(), user.DID)
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
if isCrew {
// Check if this crew member has crew:admin permission
if slices.Contains(member.Permissions, "crew:admin") {
return user, nil
}
// User is crew but doesn't have admin permission
return nil, NewAuthError("crew:admin", "crew member lacks permission", "crew:admin")
}
// User is neither owner nor authorized crew
return nil, NewAuthError("crew:admin", "user is not a crew member", "crew:admin")
}
// ValidateManifestPurger authenticates the caller and verifies they may purge
// the records associated with the given manifest URI. Authorization rules:
// 1. Hold captain (owner) — may purge any manifest's records.
// 2. Crew member with crew:admin permission — may purge any manifest's records.
// 3. Crew member whose DID matches the manifest URI's DID — may purge their
// own manifests (cleanup after delete-tag / delete-untagged).
//
// Rule 3 is what makes /delete-all-untagged work on shared/host holds where
// the calling sailor is crew with only blob:write — they're authorized to
// remove the layer/scan/config records they created.
func ValidateManifestPurger(r *http.Request, pds *HoldPDS, httpClient HTTPClient, manifestURI string) (*ValidatedUser, error) {
authHeader := r.Header.Get("Authorization")
var user *ValidatedUser
var err error
if strings.HasPrefix(authHeader, "Bearer ") {
user, err = ValidateServiceToken(r, pds.did, httpClient)
if err != nil {
return nil, fmt.Errorf("service token authentication failed: %w", err)
}
} else if strings.HasPrefix(authHeader, "DPoP ") {
user, err = ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
}
} else {
return nil, ErrInvalidAuthScheme
}
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
if user.DID == captain.Owner {
return user, nil
}
_, crewRec, crewErr := pds.GetCrewMemberByDID(r.Context(), user.DID)
if crewErr != nil {
return nil, NewAuthError("manifest:purge",
"caller is not the hold owner or a crew member",
"captain", "crew:admin", "manifest-owner+crew")
}
if slices.Contains(crewRec.Permissions, "crew:admin") {
return user, nil
}
manifestDID := manifestURIDID(manifestURI)
if manifestDID != "" && user.DID == manifestDID {
return user, nil
}
return nil, NewAuthError("manifest:purge",
"crew member may only purge their own manifests",
"crew:admin", "manifest-owner")
}
// manifestURIDID extracts the DID from an at:// URI, or returns "" if the URI
// is malformed. Format: at://<did>/<collection>/<rkey>.
func manifestURIDID(uri string) string {
rest, ok := strings.CutPrefix(uri, "at://")
if !ok {
return ""
}
i := strings.Index(rest, "/")
if i <= 0 {
return ""
}
return rest[:i]
}
// ValidateBlobWriteAccess validates that the request has valid authentication
// and that the authenticated user is either the hold owner or a crew member with blob:write permission.
// Supports two authentication methods:
// 1. Service tokens (Bearer tokens from com.atproto.server.getServiceAuth) - for AppView access
// 2. DPoP + OAuth tokens - for direct user access
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// Try service token validation first (for AppView access)
authHeader := r.Header.Get("Authorization")
var user *ValidatedUser
var err error
if strings.HasPrefix(authHeader, "Bearer ") {
// Service token authentication
user, err = ValidateServiceToken(r, pds.did, httpClient)
if err != nil {
return nil, fmt.Errorf("service token authentication failed: %w", err)
}
} else if strings.HasPrefix(authHeader, "DPoP ") {
// DPoP + OAuth authentication (direct user access)
user, err = ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
}
} else {
return nil, ErrInvalidAuthScheme
}
// Get captain record to check owner and public settings
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// Check if user is the owner (always has write access)
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with blob:write permission
member, isCrew, err := pds.lookupCrewMember(r.Context(), user.DID)
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
if isCrew {
// Check if this crew member has blob:write permission
if slices.Contains(member.Permissions, "blob:write") {
return user, nil
}
// User is crew but doesn't have write permission
return nil, NewAuthError("blob:write", "crew member lacks permission", "blob:write")
}
// User is neither owner nor authorized crew
return nil, NewAuthError("blob:write", "user is not a crew member", "blob:write")
}
// ValidateBlobReadAccess validates that the request has read access to blobs
// If captain.public = true: No auth required (returns nil user to indicate public access)
// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read or blob:write permission).
// Note: blob:write implicitly grants blob:read access.
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
// If scannerSecret is non-empty, a Bearer token matching it grants full read access (for scanner blob fetches).
func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient, scannerSecret string) (*ValidatedUser, error) {
// Get captain record to check public setting
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// If hold is public, allow access without authentication
if captain.Public {
return nil, nil // nil user indicates public access
}
// Private hold - require authentication (accept both service tokens and DPoP)
authHeader := r.Header.Get("Authorization")
var user *ValidatedUser
if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
// Check if this is a scanner using the shared secret
if scannerSecret != "" && after == scannerSecret {
return &ValidatedUser{DID: "scanner"}, nil
}
// Service token authentication (from AppView via getServiceAuth)
user, err = ValidateServiceToken(r, pds.did, httpClient)
if err != nil {
return nil, fmt.Errorf("service token authentication failed: %w", err)
}
} else if strings.HasPrefix(authHeader, "DPoP ") {
// DPoP + OAuth authentication (direct user access)
user, err = ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
}
} else {
return nil, ErrInvalidAuthScheme
}
// Check if user is the owner (always has read access)
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with blob:read or blob:write permission
// Note: blob:write implicitly grants blob:read access
member, isCrew, err := pds.lookupCrewMember(r.Context(), user.DID)
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
if isCrew {
// Check if this crew member has blob:read or blob:write permission
// blob:write implicitly grants read access (can't push without pulling)
if slices.Contains(member.Permissions, "blob:read") ||
slices.Contains(member.Permissions, "blob:write") {
return user, nil
}
// User is crew but doesn't have read or write permission
return nil, NewAuthError("blob:read", "crew member lacks permission", "blob:read", "blob:write")
}
// User is neither owner nor authorized crew
return nil, NewAuthError("blob:read", "user is not a crew member", "blob:read", "blob:write")
}
// ServiceTokenClaims represents the claims in a service token JWT
type ServiceTokenClaims struct {
jwt.RegisteredClaims
}
// ValidateServiceToken validates a service token JWT from com.atproto.server.getServiceAuth
// This validates the JWT signature using the issuer's (PDS) public key from their DID document
// Returns the user DID from the iss claim if validation succeeds
func ValidateServiceToken(r *http.Request, holdDID string, httpClient HTTPClient) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, ErrMissingAuthHeader
}
// Check for Bearer authorization scheme
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return nil, ErrInvalidAuthFormat
}
if parts[0] != "Bearer" {
return nil, fmt.Errorf("expected Bearer authorization scheme, got: %s", parts[0])
}
tokenString := parts[1]
if tokenString == "" {
return nil, ErrMissingToken
}
slog.Debug("Validating service token", "holdDID", holdDID)
// Manually parse JWT (bypass golang-jwt since it doesn't support ES256K algorithm used by ATProto)
// Split token: header.payload.signature
tokenParts := strings.Split(tokenString, ".")
if len(tokenParts) != 3 {
return nil, ErrInvalidJWTFormat
}
// Decode payload (second part) to extract claims
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}
// Parse claims from JSON
var claims ServiceTokenClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
}
// Get issuer (user DID)
issuerDID := claims.Issuer
if issuerDID == "" {
return nil, ErrMissingISSClaim
}
// Verify audience matches this hold service
audiences, err := claims.GetAudience()
if err != nil {
return nil, fmt.Errorf("failed to get audience: %w", err)
}
if len(audiences) == 0 || audiences[0] != holdDID {
return nil, fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
}
// Verify expiration
exp, err := claims.GetExpirationTime()
if err != nil {
return nil, fmt.Errorf("failed to get expiration: %w", err)
}
if exp != nil && time.Now().After(exp.Time) {
return nil, ErrTokenExpired
}
// Verify JWT signature using ATProto's secp256k1 crypto
// Signature is over "header.payload"
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
// Decode signature (base64url)
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
if err != nil {
return nil, fmt.Errorf("failed to decode signature: %w", err)
}
// Fetch public key from issuer's DID document
publicKey, err := fetchPublicKeyFromDID(r.Context(), issuerDID)
if err != nil {
return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err)
}
// Verify signature using indigo's crypto (handles secp256k1)
if err := publicKey.HashAndVerify(signedData, signature); err != nil {
return nil, fmt.Errorf("signature verification failed: %w", err)
}
slog.Debug("Successfully validated service token", "userDID", issuerDID)
// Return validated user
return &ValidatedUser{
DID: issuerDID,
Handle: "", // Not available in service token
PDS: "", // Not needed for authorization
Authorized: true,
}, nil
}
// ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256).
// It resolves the appview's DID document to extract the P-256 public key, then verifies
// the JWT signature, issuer (iss), and audience (aud).
//
// Returns the subject (sub) claim which is the user DID being acted upon.
func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return "", ErrMissingAuthHeader
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", fmt.Errorf("expected Bearer authorization scheme")
}
tokenString := parts[1]
if tokenString == "" {
return "", ErrMissingToken
}
// Manually parse JWT
tokenParts := strings.Split(tokenString, ".")
if len(tokenParts) != 3 {
return "", ErrInvalidJWTFormat
}
// Decode and parse claims
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
if err != nil {
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
}
var claims ServiceTokenClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return "", fmt.Errorf("failed to unmarshal claims: %w", err)
}
// Verify issuer matches configured appview DID.
// In a testmode build the appview and hold often address each other under
// different did:web hosts (browser-facing 127.0.0.1 vs docker bridge IP), so
// the label won't match — signature verification below still uses the
// configured appviewDID's public key, so a forged token from another signer
// would fail.
if claims.Issuer != appviewDID {
if atproto.TestModeBuild {
slog.Warn("Appview token issuer mismatch tolerated in testmode build",
"expected", appviewDID, "got", claims.Issuer)
} else {
return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
}
}
// Verify audience matches this hold's DID
audiences, err := claims.GetAudience()
if err != nil {
return "", fmt.Errorf("failed to get audience: %w", err)
}
if len(audiences) == 0 || audiences[0] != holdDID {
return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
}
// Verify expiration
exp, err := claims.GetExpirationTime()
if err != nil {
return "", fmt.Errorf("failed to get expiration: %w", err)
}
if exp != nil && time.Now().After(exp.Time) {
return "", ErrTokenExpired
}
// Get subject (user DID)
subject, err := claims.GetSubject()
if err != nil || subject == "" {
return "", ErrMissingSubClaim
}
// Fetch appview public key from its DID document. Either P-256 (ES256)
// or K-256 (ES256K) is accepted — the curve is encoded in the multikey
// prefix, and HashAndVerifyLenient rejects signatures from the wrong
// curve automatically.
pubKey, err := fetchAppviewPublicKey(r.Context(), appviewDID)
if err != nil {
return "", fmt.Errorf("failed to fetch appview public key: %w", err)
}
// Verify JWT signature
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
if err != nil {
return "", fmt.Errorf("failed to decode signature: %w", err)
}
if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
// The identity directory caches the appview's DID document for up to
// 24h. If the appview re-keyed since (a fresh database, a rotated
// OAuth key), the cached key is stale and every token fails until the
// entry expires. Purge and try once more against a fresh document
// before rejecting.
if perr := atproto.InvalidateIdentity(r.Context(), appviewDID); perr != nil {
return "", fmt.Errorf("signature verification failed: %w", err)
}
pubKey, ferr := fetchAppviewPublicKey(r.Context(), appviewDID)
if ferr != nil {
return "", fmt.Errorf("signature verification failed: %w", err)
}
if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
return "", fmt.Errorf("signature verification failed: %w", err)
}
}
// Replay check: when `jti` is present, refuse to honour the same one
// twice within its TTL.
// TODO: hard-require jti once all appview deployments are confirmed to
// emit it. For now we accept missing jti with a warning so a partial
// rollout doesn't break tier updates.
if claims.ID == "" {
slog.Warn("Appview token missing jti claim; accepting for now",
"appviewDID", appviewDID, "userDID", subject)
} else if exp != nil && sharedAppviewJTICache.Seen(claims.ID, exp.Time) {
return "", ErrTokenReplayed
}
slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject)
return subject, nil
}
// appviewKeyFragments lists the verification-method fragments an appview may
// publish its signing key under, in preference order. The appview's own DID
// document uses #appview; #atproto is the conventional fragment and what the
// test fixtures serve.
var appviewKeyFragments = []string{"appview", "atproto"}
// fetchAppviewPublicKey resolves the appview's DID document through the shared
// identity directory and returns its verification key. Accepts any key the
// directory can parse (P-256 or K-256): a known fragment first, then whatever
// else the document declares.
//
// The directory is what makes this work in every build: production resolves
// did:web over HTTPS with indigo's hardening, and a `-tags testmode` build
// resolves a port-qualified or loopback did:web over plain HTTP.
func fetchAppviewPublicKey(ctx context.Context, did string) (atcrypto.PublicKey, error) {
didParsed, err := syntax.ParseDID(did)
if err != nil {
return nil, fmt.Errorf("invalid appview DID %q: %w", did, err)
}
ident, err := atproto.GetDirectory().LookupDID(ctx, didParsed)
if err != nil {
return nil, fmt.Errorf("failed to resolve appview DID %s: %w", did, err)
}
for _, fragment := range appviewKeyFragments {
if pubKey, err := ident.GetPublicKey(fragment); err == nil {
return pubKey, nil
}
}
for fragment := range ident.Keys {
if slices.Contains(appviewKeyFragments, fragment) {
continue
}
if pubKey, err := ident.GetPublicKey(fragment); err == nil {
return pubKey, nil
}
}
return nil, fmt.Errorf("no usable verification key in DID document for %s", did)
}
// fetchPublicKeyFromDID fetches the public key from a DID document
// Supports did:plc and did:web
// Returns the atcrypto.PublicKey for signature verification
func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
// Use indigo's identity resolution
directory := atproto.GetDirectory()
atID, err := syntax.ParseAtIdentifier(did)
if err != nil {
return nil, fmt.Errorf("invalid DID format: %w", err)
}
ident, err := directory.Lookup(ctx, atID)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
}
// Get the public key using indigo's built-in method
// This returns an atcrypto.PublicKey (secp256k1)
publicKey, err := ident.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key from DID: %w", err)
}
return publicKey, nil
}