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.
at main 174 lines 5.5 kB view raw
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}