diff --git a/oauth/client/client.go b/oauth/client/client.go index 3f3a1d5..64cb884 100644 --- a/oauth/client/client.go +++ b/oauth/client/client.go @@ -1,8 +1,38 @@ package client -import "github.com/lestrrat-go/jwx/v2/jwk" +import ( + "net/url" + + "github.com/lestrrat-go/jwx/v2/jwk" +) type Client struct { - Metadata *Metadata - JWKS jwk.Key + Metadata *Metadata + JWKS jwk.Key + IsLocalhostClient bool +} + +func (c *Client) IsRedirectURIAllowed(requestedURI string) bool { + if c.IsLocalhostClient { + ru, err := url.Parse(requestedURI) + if err != nil || ru.Scheme != "http" || !isLoopbackHost(ru.Hostname()) { + return false + } + for _, registered := range c.Metadata.RedirectURIs { + reg, err := url.Parse(registered) + if err != nil { + continue + } + if reg.Hostname() == ru.Hostname() && reg.Path == ru.Path { + return true + } + } + return false + } + for _, uri := range c.Metadata.RedirectURIs { + if uri == requestedURI { + return true + } + } + return false } diff --git a/oauth/client/manager.go b/oauth/client/manager.go index 6b0f077..cbe2d72 100644 --- a/oauth/client/manager.go +++ b/oauth/client/manager.go @@ -85,12 +85,17 @@ func (cm *Manager) GetClient(ctx context.Context, clientId string) (*Client, err } return &Client{ - Metadata: metadata, - JWKS: jwks, + Metadata: metadata, + JWKS: jwks, + IsLocalhostClient: isLocalhostClientID(clientId), }, nil } func (cm *Manager) getClientMetadata(ctx context.Context, clientId string) (*Metadata, error) { + if isLocalhostClientID(clientId) { + return buildLocalhostVirtualMetadata(clientId) + } + cached, ok := cm.metadataCache.Get(clientId) if !ok { req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil) @@ -395,6 +400,65 @@ func validateAndParseMetadata(clientId string, b []byte) (*Metadata, error) { return &metadata, nil } +func isLocalhostClientID(clientId string) bool { + u, err := url.Parse(clientId) + if err != nil { + return false + } + return u.Scheme == "http" && + u.Hostname() == "localhost" && + u.Port() == "" && + (u.Path == "" || u.Path == "/") +} + +func buildLocalhostVirtualMetadata(clientId string) (*Metadata, error) { + u, err := url.Parse(clientId) + if err != nil { + return nil, fmt.Errorf("error parsing localhost client_id: %w", err) + } + + q := u.Query() + + redirectURIs := q["redirect_uri"] + if len(redirectURIs) == 0 { + redirectURIs = []string{"http://127.0.0.1/", "http://[::1]/"} + } + + for _, ruri := range redirectURIs { + ru, err := url.Parse(ruri) + if err != nil { + return nil, fmt.Errorf("invalid redirect_uri %q: %w", ruri, err) + } + if ru.Scheme != "http" || !isLoopbackHost(ru.Hostname()) { + return nil, fmt.Errorf("localhost client redirect_uri must use a loopback address, got %q", ruri) + } + } + + scope := q.Get("scope") + if scope == "" { + scope = "atproto" + } else if !slices.Contains(strings.Split(scope, " "), "atproto") { + scope = "atproto " + scope + } + + return &Metadata{ + ClientID: clientId, + ClientName: "Development client", + ClientURI: "http://localhost", + RedirectURIs: redirectURIs, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + ApplicationType: "native", + DpopBoundAccessTokens: true, + Scope: scope, + TokenEndpointAuthMethod: "none", + }, nil +} + +func isLoopbackHost(hostname string) bool { + return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" +} + func isLocalHostname(hostname string) bool { pts := strings.Split(hostname, ".") if len(pts) < 2 {