Stateless auth proxy that converts AT Protocol native apps from public to confidential OAuth clients. Deploy once, get 180-day refresh tokens instead of 24-hour ones.
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "slices"
11 "sync"
12 "time"
13)
14
15const (
16 authServerMetadataCacheTTL = 5 * time.Minute
17 maxMetadataResponseBytes = 256 << 10
18 maxUpstreamResponseBodySize = 1 << 20
19)
20
21type authServerMetadata struct {
22 Issuer string `json:"issuer"`
23 TokenEndpoint string `json:"token_endpoint"`
24 TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
25 TokenEndpointAuthSigningAlgsSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
26 RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"`
27 PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
28}
29
30type cachedAuthServerMetadata struct {
31 metadata *authServerMetadata
32 expiresAt time.Time
33}
34
35var (
36 authServerMetadataCache = struct {
37 mu sync.Mutex
38 entries map[string]cachedAuthServerMetadata
39 }{
40 entries: make(map[string]cachedAuthServerMetadata),
41 }
42
43 metadataClient = newPublicHTTPClient(10 * time.Second)
44)
45
46func clearAuthServerMetadataCache() {
47 authServerMetadataCache.mu.Lock()
48 defer authServerMetadataCache.mu.Unlock()
49 authServerMetadataCache.entries = make(map[string]cachedAuthServerMetadata)
50}
51
52func ResolveAuthServerMetadata(ctx context.Context, issuer string) (*authServerMetadata, error) {
53 issuerURL, err := ValidateIssuer(issuer)
54 if err != nil {
55 return nil, invalidRequestError("invalid issuer")
56 }
57
58 now := time.Now()
59
60 authServerMetadataCache.mu.Lock()
61 entry, ok := authServerMetadataCache.entries[issuer]
62 if ok && now.Before(entry.expiresAt) {
63 authServerMetadataCache.mu.Unlock()
64 return entry.metadata, nil
65 }
66 authServerMetadataCache.mu.Unlock()
67
68 metadataURL := issuerURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-authorization-server"})
69 req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL.String(), nil)
70 if err != nil {
71 return nil, upstreamRequestError("failed to create metadata request")
72 }
73
74 resp, err := metadataClient.Do(req)
75 if err != nil {
76 return nil, upstreamRequestError("failed to fetch authorization server metadata")
77 }
78 defer resp.Body.Close()
79
80 if resp.StatusCode != http.StatusOK {
81 return nil, upstreamRequestError(fmt.Sprintf("authorization server metadata returned HTTP %d", resp.StatusCode))
82 }
83
84 body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetadataResponseBytes+1))
85 if err != nil {
86 return nil, upstreamRequestError("failed to read authorization server metadata")
87 }
88 if len(body) > maxMetadataResponseBytes {
89 return nil, upstreamRequestError("authorization server metadata response was too large")
90 }
91
92 var metadata authServerMetadata
93 if err := json.Unmarshal(body, &metadata); err != nil {
94 return nil, upstreamRequestError("authorization server metadata was not valid JSON")
95 }
96
97 if err := metadata.Validate(issuer); err != nil {
98 return nil, invalidRequestError(err.Error())
99 }
100
101 authServerMetadataCache.mu.Lock()
102 authServerMetadataCache.entries[issuer] = cachedAuthServerMetadata{
103 metadata: &metadata,
104 expiresAt: now.Add(authServerMetadataCacheTTL),
105 }
106 authServerMetadataCache.mu.Unlock()
107
108 return &metadata, nil
109}
110
111func (m *authServerMetadata) Validate(expectedIssuer string) error {
112 if _, err := ValidateIssuer(m.Issuer); err != nil {
113 return fmt.Errorf("issuer metadata contained an invalid issuer")
114 }
115 if m.Issuer != expectedIssuer {
116 return fmt.Errorf("issuer metadata did not match the requested issuer")
117 }
118 if _, err := validateEndpointURL(m.TokenEndpoint); err != nil {
119 return fmt.Errorf("issuer metadata contained an invalid token_endpoint")
120 }
121 if !slices.Contains(m.TokenEndpointAuthMethodsSupported, "private_key_jwt") {
122 return fmt.Errorf("issuer metadata does not support private_key_jwt")
123 }
124 if !slices.Contains(m.TokenEndpointAuthSigningAlgsSupported, "ES256") {
125 return fmt.Errorf("issuer metadata does not support ES256 client assertions")
126 }
127 if m.PushedAuthorizationRequestEndpoint != "" {
128 if _, err := validateEndpointURL(m.PushedAuthorizationRequestEndpoint); err != nil {
129 return fmt.Errorf("issuer metadata contained an invalid pushed_authorization_request_endpoint")
130 }
131 }
132 if m.RequirePushedAuthorizationRequests && m.PushedAuthorizationRequestEndpoint == "" {
133 return fmt.Errorf("issuer metadata requires PAR but does not advertise a PAR endpoint")
134 }
135
136 return nil
137}
138
139func ValidateTokenEndpointForIssuer(ctx context.Context, issuer string, tokenEndpoint string) error {
140 if err := ValidateTokenEndpoint(tokenEndpoint); err != nil {
141 return invalidRequestError("invalid token_endpoint")
142 }
143
144 metadata, err := ResolveAuthServerMetadata(ctx, issuer)
145 if err != nil {
146 return err
147 }
148
149 if metadata.TokenEndpoint != tokenEndpoint {
150 return invalidRequestError("token_endpoint does not match issuer metadata")
151 }
152
153 return nil
154}
155
156func ValidatePAREndpointForIssuer(ctx context.Context, issuer string, parEndpoint string) error {
157 if err := ValidatePAREndpoint(parEndpoint); err != nil {
158 return invalidRequestError("invalid par_endpoint")
159 }
160
161 metadata, err := ResolveAuthServerMetadata(ctx, issuer)
162 if err != nil {
163 return err
164 }
165
166 if metadata.PushedAuthorizationRequestEndpoint == "" {
167 return invalidRequestError("issuer does not advertise a pushed_authorization_request_endpoint")
168 }
169 if metadata.PushedAuthorizationRequestEndpoint != parEndpoint {
170 return invalidRequestError("par_endpoint does not match issuer metadata")
171 }
172
173 return nil
174}