A locally focused bluesky appview
1package xrpc
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "strings"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 "github.com/labstack/echo/v4"
11 "github.com/lestrrat-go/jwx/v2/jwt"
12)
13
14// requireAuth is middleware that requires authentication
15func (s *Server) requireAuth(next echo.HandlerFunc) echo.HandlerFunc {
16 return func(c echo.Context) error {
17 viewer, err := s.authenticate(c)
18 if err != nil {
19 return XRPCError(c, http.StatusUnauthorized, "AuthenticationRequired", err.Error())
20 }
21 c.Set("viewer", viewer)
22 return next(c)
23 }
24}
25
26// optionalAuth is middleware that optionally authenticates
27func (s *Server) optionalAuth(next echo.HandlerFunc) echo.HandlerFunc {
28 return func(c echo.Context) error {
29 viewer, _ := s.authenticate(c)
30 if viewer != "" {
31 c.Set("viewer", viewer)
32 }
33 return next(c)
34 }
35}
36
37// authenticate extracts and validates the JWT from the Authorization header
38// Returns the viewer DID if valid, empty string otherwise
39func (s *Server) authenticate(c echo.Context) (string, error) {
40 authHeader := c.Request().Header.Get("Authorization")
41 if authHeader == "" {
42 return "", fmt.Errorf("missing authorization header")
43 }
44
45 // Extract Bearer token
46 parts := strings.Split(authHeader, " ")
47 if len(parts) != 2 || parts[0] != "Bearer" {
48 return "", fmt.Errorf("invalid authorization header format")
49 }
50
51 tokenString := parts[1]
52
53 // Parse JWT without signature validation (for development)
54 // In production, you'd want to validate the signature using the issuer's public key
55 token, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false))
56 if err != nil {
57 return "", fmt.Errorf("failed to parse token: %w", err)
58 }
59
60 // Extract the user's DID - try both "sub" (PDS tokens) and "iss" (service tokens)
61 var userDID string
62
63 // First try "sub" claim (used by PDS tokens and entryway tokens)
64 sub := token.Subject()
65 if sub != "" && strings.HasPrefix(sub, "did:") {
66 userDID = sub
67 } else {
68 // Fall back to "iss" claim (used by some service tokens)
69 iss := token.Issuer()
70 if iss != "" && strings.HasPrefix(iss, "did:") {
71 userDID = iss
72 }
73 }
74
75 if userDID == "" {
76 return "", fmt.Errorf("missing 'sub' or 'iss' claim with DID in token")
77 }
78
79 // Optional: check scope if present
80 scope, ok := token.Get("scope")
81 if ok {
82 scopeStr, _ := scope.(string)
83 // Valid scopes are: com.atproto.access, com.atproto.appPass, com.atproto.appPassPrivileged
84 if scopeStr != "com.atproto.access" && scopeStr != "com.atproto.appPass" && scopeStr != "com.atproto.appPassPrivileged" {
85 return "", fmt.Errorf("invalid token scope: %s", scopeStr)
86 }
87 }
88
89 return userDID, nil
90}
91
92// resolveActor resolves an actor identifier (handle or DID) to a DID
93func (s *Server) resolveActor(ctx context.Context, actor string) (string, error) {
94 // If it's already a DID, return it
95 if strings.HasPrefix(actor, "did:") {
96 return actor, nil
97 }
98
99 // Otherwise, resolve the handle
100 resp, err := s.dir.LookupHandle(ctx, syntax.Handle(actor))
101 if err != nil {
102 return "", fmt.Errorf("failed to resolve handle: %w", err)
103 }
104
105 return resp.DID.String(), nil
106}