···55 "fmt"
66 "net/url"
77 "slices"
88+ "strings"
89910 "github.com/bluesky-social/indigo/atproto/crypto"
1011 "github.com/bluesky-social/indigo/atproto/syntax"
1112)
12131313-var ErrInvalidAuthServerMetadata = errors.New("invalid auth server metadata")
1414+var (
1515+ ErrInvalidAuthServerMetadata = errors.New("invalid auth server metadata")
1616+ ErrInvalidClientMetadata = errors.New("invalid client metadata doc")
1717+ CLIENT_ASSERTION_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
1818+)
14191520type JWKS struct {
1621 Keys []crypto.JWK `json:"keys"`
···18231924// Expected response type from looking up OAuth Protected Resource information on a server (eg, a PDS instance)
2025type ProtectedResourceMetadata struct {
2121- // TODO: are there other fields worth including?
2626+ // are there other fields worth including?
22272328 AuthorizationServers []string `json:"authorization_servers"`
2429}
···7378 PolicyURI *string `json:"policy_uri,omitempty"`
7479}
75808181+// returns 'true' if client metadata indicates that this is a confidential client
8282+func (m *ClientMetadata) IsConfidential() bool {
8383+ if (m.JWKSUri != nil || len(m.JWKS) > 0) && (m.TokenEndpointAuthMethod != nil && *m.TokenEndpointAuthMethod == "private_key_jwt") {
8484+ return true
8585+ }
8686+8787+ return false
8888+}
8989+7690func (m *ClientMetadata) Validate(clientID string) error {
7777- // XXX: validate field syntax, possibly including consistency against a provided URL / clientID
7878- // XXX: copy from python tutorial
9191+9292+ if m.ClientID == "" || m.ClientID != clientID {
9393+ return fmt.Errorf("%w: client_id", ErrInvalidClientMetadata)
9494+ }
9595+9696+ if m.ApplicationType != nil && !slices.Contains([]string{"web", "native"}, *m.ApplicationType) {
9797+ return fmt.Errorf("%w: application_type must be 'web', 'native', or undefined", ErrInvalidClientMetadata)
9898+ }
9999+100100+ if !slices.Contains(m.GrantTypes, "authorization_code") {
101101+ return fmt.Errorf("%w: grant_type must include 'authorization_code'", ErrInvalidClientMetadata)
102102+ }
103103+104104+ scopes := strings.Split(m.Scope, " ")
105105+ if !slices.Contains(scopes, "atproto") {
106106+ return fmt.Errorf("%w: scope must include 'atproto'", ErrInvalidClientMetadata)
107107+ }
108108+109109+ if !slices.Contains(m.ResponseTypes, "code") {
110110+ return fmt.Errorf("%w: response_types must include 'code'", ErrInvalidClientMetadata)
111111+ }
112112+113113+ if len(m.RedirectURIs) == 0 {
114114+ return fmt.Errorf("%w: redirect_uris must have at least one element", ErrInvalidClientMetadata)
115115+ }
116116+117117+ // 'web' redirect URLs have more restrictions
118118+ if m.ApplicationType == nil || *m.ApplicationType == "web" {
119119+ for _, ru := range m.RedirectURIs {
120120+ u, err := url.Parse(ru)
121121+ if err != nil {
122122+ return fmt.Errorf("%w: invalid web redirect_uris: %w", ErrInvalidClientMetadata, err)
123123+ }
124124+ if u.Scheme != "https" {
125125+ return fmt.Errorf("%w: web redirect_uris must have 'https' scheme", ErrInvalidClientMetadata)
126126+ }
127127+ }
128128+ }
129129+130130+ if m.TokenEndpointAuthSigningAlg != nil && *m.TokenEndpointAuthSigningAlg == "none" {
131131+ // NOTE: what if this is a public client?
132132+ return fmt.Errorf("%w: token_endpoint_auth_signing_alg must not be 'none'", ErrInvalidClientMetadata)
133133+ }
134134+135135+ if !m.DpopBoundAccessTokens {
136136+ return fmt.Errorf("%w: dpop_bound_access_tokens must be true (DPoP is required)", ErrInvalidClientMetadata)
137137+ }
138138+139139+ if m.JWKSUri != nil && *m.JWKSUri == "" {
140140+ return fmt.Errorf("%w: jwks_uri must be valid URL (when provided)", ErrInvalidClientMetadata)
141141+ }
142142+143143+ // NOTE: metadata URLs are not validated (they are not an error for overall metadata doc)
144144+79145 return nil
80146}
81147···128194}
129195130196func (m *AuthServerMetadata) Validate(serverURL string) error {
131131- // XXX: check that Issuer matches domain this metadata document was fetched from
132197133198 if m.Issuer == "" {
134199 return fmt.Errorf("%w: empty issuer", ErrInvalidAuthServerMetadata)
135200 }
136201 u, err := url.Parse(m.Issuer)
137202 if err != nil {
138138- return err
203203+ return fmt.Errorf("%w: invalid issuer URL: %w", ErrInvalidAuthServerMetadata, err)
139204 }
140205 if u.Scheme != "https" || u.Port() != "" || u.Path != "" || u.Fragment != "" || u.RawQuery != "" {
141206 return fmt.Errorf("%w: issuer URL", ErrInvalidAuthServerMetadata)
142207 }
208208+209209+ // check that Issuer matches domain this metadata document was fetched from
210210+ srvu, err := url.Parse(serverURL)
211211+ if err != nil {
212212+ return fmt.Errorf("%w: invalid request URL: %w", ErrInvalidAuthServerMetadata, err)
213213+ }
214214+ if u.Scheme != srvu.Scheme || u.Host != srvu.Host {
215215+ return fmt.Errorf("%w: issuer must match request URL", ErrInvalidAuthServerMetadata)
216216+ }
217217+143218 if !slices.Contains(m.ResponseTypesSupported, "code") {
144219 return fmt.Errorf("%w: response_types_supported must include 'code'", ErrInvalidAuthServerMetadata)
145220 }
···237312 // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
238313 AccountDID *syntax.DID `json:"account_did,omitempty"`
239314240240- // XXX: if we started from handle, should probably trust / use the resolved DID?
241241- //AccountHandle *syntax.Handle `json:"account_handle,omitempty"`
242242-243243- // Base URL of the "resource server" (eg, PDS), if the auth flow started with a host URL instead of an account identifier.
244244- HostURL *string `json:"host_url,omitempty"`
245245-246315 // OAuth scope string (space-separated list)
247316 Scope string `json:"scope"`
248317···256325 DpopAuthServerNonce string `json:"dpop_authserver_nonce"`
257326258327 // The secret cryptographic key generated by the client for this specific OAuth session
259259- // TODO: better name for this field
260260- DpopKeyMultibase string `json:"dpop_privatekey_multibase"`
328328+ DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
261329}
262330263331// Persisted information about an OAuth session. Used to resume an active session.
···286354 DpopHostNonce string `json:"dpop_host_nonce"`
287355288356 // The secret cryptographic key generated by the client for this specific OAuth session
289289- DpopKeyMultibase string `json:"dpop_secret_key"`
357357+ DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
290358291359 // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT
292360}
···296364 // Client ID, aka client metadata URL
297365 ClientID string `url:"client_id"`
298366299299- // Only used in initial token request. Client-specified URL that will get redirected to by auth server at end of user auth flow
300300- // XXX: is this really needed in the initial token request?
367367+ // Only used in initial token request. Auth server will validate that this matches the redirect URI used during the auth flow (resulting in the auth code)
301368 RedirectURI string `url:"redirect_uri"`
302369303370 // Always `authorization_code`
304371 GrantType string `url:"grant_type"`
305372306306- // Refresh token.
373373+ // Refresh token
307374 RefreshToken string `url:"refresh_token"`
308375376376+ // Authorization Code provided by the Auth Server via callback at the end of the auth request flow
309377 Code string `url:"code"`
310378311311- // PKCE verifier string. Only included in initial token request.
379379+ // PKCE verifier string. Only included in initial token request
312380 CodeVerifier string `url:"code_verifier"`
313381314314- // Always "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
315315- ClientAssertionType string `url:"client_assertion_type"`
382382+ // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
383383+ ClientAssertionType *string `url:"client_assertion_type"`
316384317317- // Confidential client signed JWT
318318- ClientAssertion string `url:"client_assertion"`
385385+ // For confidential clients, the signed client assertion JWT
386386+ ClientAssertion *string `url:"client_assertion"`
319387}
320388321389// The fields which are included in a token refresh request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
···329397 // Refresh token.
330398 RefreshToken string `url:"refresh_token"`
331399332332- // Always "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
333333- ClientAssertionType string `url:"client_assertion_type"`
400400+ // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
401401+ ClientAssertionType *string `url:"client_assertion_type"`
334402335335- // Confidential client signed JWT
336336- ClientAssertion string `url:"client_assertion"`
403403+ // For confidential clients, the signed client assertion JWT
404404+ ClientAssertion *string `url:"client_assertion"`
337405}
338406339407// Expected respose from Auth Server token endpoint, both for initial token request and for refresh requests.