From 535f9a6c352d140d883f84792cc79a8bbded52f5 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Sat, 3 Oct 2026 00:51:38 +0800 Subject: [PATCH] feat(server): accept native-app redirect URIs for clients registered as native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8252 gives native apps three redirects: a private-use URI scheme (§7.1), a claimed https URI (§7.2) and loopback http on any port (§7.3). FAPI 2.0 forbids only non-loopback http. Until now the server accepted https everywhere and loopback http under development assurance only, so a mobile wallet's com.example.app:/callback was refused at PAR whatever its registration said. - storage.RegisteredClientConfig.ApplicationType: ApplicationTypeWeb (the zero value, today's behaviour) or ApplicationTypeNative, after OIDC Registration's application_type; RFC 8252 §8.4 asks a server to record it. - NewRegisteredClient checks a native client's redirect URIs. Each must be a private-use scheme in reverse-domain form (with a ".", in scheme:/path form), loopback http to the IP literal 127.0.0.1 or [::1] (not "localhost", §8.3), or https. - A native client's loopback redirect URI matches on any port and exactly otherwise (§7.3, §8.4). The token request still has to name the port the authorization request used. - PAR accepts these for a native client in production too. A web client is unchanged: https, plus loopback http, exact, under development assurance only. - fapi.ParseRedirectURL and AllowPrivateUseScheme carry a redirect destination that has no host. ParseEndpointURL is unchanged. Automatically registered federation clients stay web: an RP's self-published application_type isn't acted on. Co-Authored-By: Claude Opus 5.5 --- GETTING_STARTED.md | 10 +++ federation/relying_party_metadata.go | 4 + redirect_url_test.go | 60 +++++++++++++ server/complete_authorization.go | 35 +++++--- server/native_redirect_test.go | 121 ++++++++++++++++++++++++++ server/par.go | 2 +- server/par_test.go | 8 ++ storage/client_repository.go | 123 +++++++++++++++++++++++++++ storage/native_app_test.go | 79 +++++++++++++++++ url.go | 90 ++++++++++++++++++-- 10 files changed, 514 insertions(+), 18 deletions(-) create mode 100644 redirect_url_test.go create mode 100644 server/native_redirect_test.go create mode 100644 storage/native_app_test.go diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 15a4985c..27761d0b 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -110,6 +110,16 @@ that only needs access tokens — no identity layer — can drop `"openid"` from `AllowedScopes` entirely and run as plain OAuth 2.0 + FAPI 2.0; nothing else here changes. +A mobile or desktop app — a wallet, say — registers with +`ApplicationType: storage.ApplicationTypeNative`. It may then use the +redirect URIs RFC 8252 gives native apps, in production too: a +private-use scheme in reverse-domain form +(`com.example.wallet:/callback`), or loopback http to `127.0.0.1` or +`[::1]`, which matches on whatever port the app listens on. A web +client can use neither. A native client still authenticates like any +other: give each app instance its own credentials, as attestation-based +client authentication does. + ## 4. Wire `Dependencies` and construct the server ```go diff --git a/federation/relying_party_metadata.go b/federation/relying_party_metadata.go index da3f3ebf..8426b5b0 100644 --- a/federation/relying_party_metadata.go +++ b/federation/relying_party_metadata.go @@ -63,6 +63,10 @@ type OpenIDRelyingPartyMetadata struct { // ApplicationType is OpenID Connect Dynamic Client Registration // 1.0 §2's own "web" or "native" — OPTIONAL, "web" if omitted. + // Automatic registration doesn't act on it: an RP's own claim to be a + // native app, which would admit private-use and loopback redirect + // URIs, isn't one to take from its self-published metadata, so every + // automatically registered client is storage.ApplicationTypeWeb. ApplicationType string `json:"application_type,omitempty"` // TokenEndpointAuthMethod is this RP's own declared client diff --git a/redirect_url_test.go b/redirect_url_test.go new file mode 100644 index 00000000..df8fb224 --- /dev/null +++ b/redirect_url_test.go @@ -0,0 +1,60 @@ +package fapi_test + +import ( + "testing" + + fapi "github.com/idfoundry/fapigo" +) + +func TestParseRedirectURL(t *testing.T) { + native := []fapi.URLOption{fapi.AllowLoopbackHTTP(), fapi.AllowPrivateUseScheme()} + for raw, want := range map[string]bool{ + "https://rp.example/cb": true, + "org.idfoundry.oid4vcgo.demowallet:/callback": true, + "com.example.app:/cb?from=wallet": true, + "com.example-app.ios:/cb": true, + "http://127.0.0.1:51004/cb": true, + "myapp:/cb": false, // not a reverse domain (RFC 8252 §8.4) + "com.example.app:cb": false, // opaque, no path + "com.example.app://host/cb": false, // an authority + "com..app:/cb": false, + "com.-example.app:/cb": false, + "com.example.app:/cb#frag": false, + "com.example.app:": false, + "http://rp.example/cb": false, + "": false, + } { + _, err := fapi.ParseRedirectURL(raw, native...) + if got := err == nil; got != want { + t.Errorf("ParseRedirectURL(%q, native) = %v, want accepted %v", raw, err, want) + } + } + for _, raw := range []string{"com.example.app:/cb", "http://127.0.0.1:51004/cb"} { + if _, err := fapi.ParseRedirectURL(raw); err == nil { + t.Errorf("ParseRedirectURL(%q) without options = nil error, want https only", raw) + } + } + if _, err := fapi.ParseEndpointURL("com.example.app:/cb", fapi.AllowPrivateUseScheme()); err == nil { + t.Error("ParseEndpointURL accepted a private-use scheme: the option is for redirects only") + } + u, err := fapi.ParseRedirectURL("org.idfoundry.oid4vcgo.demowallet:/callback", native...) + if err != nil || u.String() != "org.idfoundry.oid4vcgo.demowallet:/callback" { + t.Errorf("ParseRedirectURL round trip = %q, %v", u.String(), err) + } +} + +// FuzzParseRedirectURL covers ParseRedirectURL on any input: it never +// panics, and with no options accepts https alone. +func FuzzParseRedirectURL(f *testing.F) { + for _, s := range []string{"https://rp.example/cb", "com.example.app:/cb", "http://[::1]:1/cb", "a.b:/", "a.b://x"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + _, _ = fapi.ParseRedirectURL(raw, fapi.AllowLoopbackHTTP(), fapi.AllowPrivateUseScheme()) + if u, err := fapi.ParseRedirectURL(raw); err == nil { + if v := u.URL(); v.Scheme != "https" { + t.Fatalf("ParseRedirectURL(%q) accepted scheme %q with no options", raw, v.Scheme) + } + } + }) +} diff --git a/server/complete_authorization.go b/server/complete_authorization.go index c32d5c79..56bcce97 100644 --- a/server/complete_authorization.go +++ b/server/complete_authorization.go @@ -232,26 +232,37 @@ func (s *Server) buildAuthorizationResponse(ctx context.Context, clientID fapi.C base.RawQuery = q.Encode() } - destination, err := s.parseRedirectURI(base.String()) + // The pushed authorization request already held redirect_uri to the + // client's registered type (parseRedirectURI); this parse only turns + // the stored value, with the response parameters added, into a URL. + destination, err := fapi.ParseRedirectURL(base.String(), fapi.AllowLoopbackHTTP(), fapi.AllowPrivateUseScheme()) if err != nil { return fapi.URL{}, newError(ErrorServerError, 500, "failed to construct redirect destination", err) } return destination, nil } -// parseRedirectURI validates raw as a redirect destination. FAPI 2.0 -// §5.3.2.2 forbids http redirect URIs except loopback redirection per -// RFC 8252 §7.3; this server permits that exception only outside -// AssuranceProduction, the same line validateAssurance draws for the -// server's own endpoints. The pushed authorization request checks the -// redirect_uri with this too, so an unacceptable one is refused there -// as invalid_request rather than surfacing as a server_error once the -// flow completes. -func (s *Server) parseRedirectURI(raw string) (fapi.URL, error) { +// parseRedirectURI validates raw as a redirect destination for client, +// at the pushed authorization request, so an unacceptable one is refused +// there as invalid_request rather than once the flow completes. FAPI 2.0 +// §5.3.2.2 forbids http redirect URIs except a native client's loopback +// redirection (RFC 8252 §7.3): +// +// - a native app (storage.ApplicationTypeNative) may use the +// redirects RFC 8252 gives it — a private-use scheme, loopback http +// or https — in production too; NewRegisteredClient checked their +// forms; +// - a web application may use https, and loopback http only outside +// AssuranceProduction, the line validateAssurance draws for the +// server's own endpoints. +func (s *Server) parseRedirectURI(client storage.RegisteredClient, raw string) (fapi.URL, error) { + if client.ApplicationType() == storage.ApplicationTypeNative { + return fapi.ParseRedirectURL(raw, fapi.AllowLoopbackHTTP(), fapi.AllowPrivateUseScheme()) + } if s.cfg.Assurance == AssuranceProduction { - return fapi.ParseEndpointURL(raw) + return fapi.ParseRedirectURL(raw) } - return fapi.ParseEndpointURL(raw, fapi.AllowLoopbackHTTP()) + return fapi.ParseRedirectURL(raw, fapi.AllowLoopbackHTTP()) } func validateGrantedScopeSubset(granted []string, requestedSpaceDelimited string) error { diff --git a/server/native_redirect_test.go b/server/native_redirect_test.go new file mode 100644 index 00000000..a647fa54 --- /dev/null +++ b/server/native_redirect_test.go @@ -0,0 +1,121 @@ +package server_test + +import ( + "context" + "testing" + + "github.com/idfoundry/fapigo/internal/clientassertion" + "github.com/idfoundry/fapigo/server" + "github.com/idfoundry/fapigo/storage" +) + +const testPrivateUseRedirectURI = "org.idfoundry.oid4vcgo.demowallet:/callback" + +// completeWith pushes redirectURI, begins and approves the +// authorization, and returns the response's query, checking it went to +// wantDestination. +func completeWith(t *testing.T, h harness, redirectURI, wantDestination string) map[string][]string { + t.Helper() + handle := beginInteractionWithRedirectURI(t, h, redirectURI) + result, err := h.server.CompleteAuthorization(context.Background(), server.CompleteAuthorizationRequest{ + Handle: handle, Result: authorizeResult(t), + }) + if err != nil { + t.Fatalf("CompleteAuthorization: %v", err) + } + return assertRedirectsTo(t, result, wantDestination) +} + +// TestNativeClientPrivateUseRedirectUnderProduction covers RFC 8252 +// §7.1 for a native client, in production: the authorization response +// goes to the app's private-use URI with code, state and iss. +func TestNativeClientPrivateUseRedirectUnderProduction(t *testing.T) { + h := newHarnessWithApplicationType(t, server.ProfileFAPISecurity, true, testPrivateUseRedirectURI, server.AssuranceProduction, storage.ApplicationTypeNative) + q := completeWith(t, h, testPrivateUseRedirectURI, testPrivateUseRedirectURI) + for _, name := range []string{"code", "state", "iss"} { + if len(q[name]) != 1 || q[name][0] == "" { + t.Errorf("response query = %v, want %s", q, name) + } + } +} + +// TestNativeClientLoopbackAnyPortUnderProduction covers RFC 8252 §7.3: +// a native client registered for http://127.0.0.1/callback is redirected +// to the port its request names, in production. +func TestNativeClientLoopbackAnyPortUnderProduction(t *testing.T) { + h := newHarnessWithApplicationType(t, server.ProfileFAPISecurity, true, "http://127.0.0.1/callback", server.AssuranceProduction, storage.ApplicationTypeNative) + q := completeWith(t, h, "http://127.0.0.1:51004/callback", "http://127.0.0.1:51004/callback") + if len(q["code"]) != 1 { + t.Fatalf("response query = %v, want a code", q) + } + // The token request names the redirect_uri the authorization request + // did (RFC 6749 §4.1.3), port and all: the registered form without + // one isn't it. + exchange := func(redirectURI string) error { + _, err := h.server.ExchangeAuthorizationCode(context.Background(), server.AuthorizationCodeExchangeRequest{ + HTTP: server.FormRequest{Parameters: exchangeFormParams(h.clientAssertion(t), q["code"][0], redirectURI, testCodeVerifier)}, + DPoPProofs: []string{createDPoPProof(t, generateKey(t), h.now)}, + }) + return err + } + if err := exchange("http://127.0.0.1/callback"); err == nil { + t.Error("ExchangeAuthorizationCode(the registered URI, without the request's port) = nil error") + } + // The refused exchange may have used up that code: authorize again for + // the one that should succeed. + q = completeWith(t, h, "http://127.0.0.1:51004/callback", "http://127.0.0.1:51004/callback") + if err := exchange("http://127.0.0.1:51004/callback"); err != nil { + t.Errorf("ExchangeAuthorizationCode(the request's redirect_uri) = %v", err) + } +} + +// TestWebClientRefusedNativeRedirects covers a web client registered for +// the same redirect URIs: none is usable, under production. +func TestWebClientRefusedNativeRedirects(t *testing.T) { + for name, tc := range map[string]struct{ registered, requested string }{ + "private-use scheme": {testPrivateUseRedirectURI, testPrivateUseRedirectURI}, + "loopback, any port": {"http://127.0.0.1/callback", "http://127.0.0.1:51004/callback"}, + "loopback, as registered": {"http://127.0.0.1/callback", "http://127.0.0.1/callback"}, + } { + t.Run(name, func(t *testing.T) { + h := newHarnessWithApplicationType(t, server.ProfileFAPISecurity, true, tc.registered, server.AssuranceProduction, storage.ApplicationTypeWeb) + _, err := pushWithRedirectURI(t, h, tc.requested) + if code := serverErrorCode(t, err); code != server.ErrorInvalidRequest { + t.Fatalf("PushAuthorizationRequest(%q) error code = %q, want %q", tc.requested, code, server.ErrorInvalidRequest) + } + }) + } +} + +// TestNativeClientPrivateUseRedirectWithJARM covers Message Signing's +// signed authorization response (JARM) to a private-use URI. +func TestNativeClientPrivateUseRedirectWithJARM(t *testing.T) { + h := newHarnessWithApplicationType(t, server.ProfileFAPISecurityWithMessageSigning, true, testPrivateUseRedirectURI, server.AssuranceProduction, storage.ApplicationTypeNative) + params := standardAuthParams(t) + params["redirect_uri"] = jsonRaw(t, testPrivateUseRedirectURI) + pushResult, err := h.server.PushAuthorizationRequest(context.Background(), server.PushAuthorizationRequest{ + HTTP: server.FormRequest{Parameters: []server.FormParameter{ + formParam("client_assertion", h.clientAssertion(t)), + formParam("client_assertion_type", clientassertion.AssertionType), + formParam("request", h.requestObject(t, params)), + }}, + }) + if err != nil { + t.Fatalf("PushAuthorizationRequest: %v", err) + } + action, err := h.server.BeginAuthorization(context.Background(), server.BeginAuthorizationRequest{RequestURI: pushResult.RequestURI.String(), ClientID: testClientID}) + if err != nil { + t.Fatalf("BeginAuthorization: %v", err) + } + required, ok := action.(server.InteractionRequired) + if !ok { + t.Fatalf("action = %T, want server.InteractionRequired", action) + } + result, err := h.server.CompleteAuthorization(context.Background(), server.CompleteAuthorizationRequest{Handle: required.Handle, Result: authorizeResult(t)}) + if err != nil { + t.Fatalf("CompleteAuthorization: %v", err) + } + if q := assertRedirectsTo(t, result, testPrivateUseRedirectURI); len(q["response"]) != 1 { + t.Errorf("response query = %v, want a JARM response", q) + } +} diff --git a/server/par.go b/server/par.go index 3e48b53f..9ffdf62a 100644 --- a/server/par.go +++ b/server/par.go @@ -664,7 +664,7 @@ func (s *Server) validateAuthorizationParameters(params map[string]json.RawMessa if !client.HasRedirectURI(redirectURI) { return nil, newError(ErrorInvalidRequest, 400, "redirect_uri is not registered for this client", nil) } - if _, err := s.parseRedirectURI(redirectURI); err != nil { + if _, err := s.parseRedirectURI(client, redirectURI); err != nil { return nil, newError(ErrorInvalidRequest, 400, "redirect_uri is not an acceptable redirect destination", err) } diff --git a/server/par_test.go b/server/par_test.go index 4994d112..2356004f 100644 --- a/server/par_test.go +++ b/server/par_test.go @@ -485,6 +485,13 @@ func newHarness(t *testing.T, profile server.Profile, allowRequestObjects bool) // for redirectURI instead of testRedirectURI, under assurance — for // exercising which redirect URIs each assurance level accepts. func newHarnessWithRedirectURI(t *testing.T, profile server.Profile, allowRequestObjects bool, redirectURI string, assurance server.AssuranceLevel) harness { + t.Helper() + return newHarnessWithApplicationType(t, profile, allowRequestObjects, redirectURI, assurance, storage.ApplicationTypeWeb) +} + +// newHarnessWithApplicationType is newHarnessWithRedirectURI for a +// client of the given application type. +func newHarnessWithApplicationType(t *testing.T, profile server.Profile, allowRequestObjects bool, redirectURI string, assurance server.AssuranceLevel, appType storage.ApplicationType) harness { t.Helper() now := time.Now() key := generateKey(t) @@ -500,6 +507,7 @@ func newHarnessWithRedirectURI(t *testing.T, profile server.Profile, allowReques ClientAssertionAlgorithm: fapi.ES256, RequestObjectAlgorithm: reqObjAlg, AllowedScopes: []string{"openid", "accounts", "offline_access"}, + ApplicationType: appType, }) if err != nil { t.Fatalf("NewRegisteredClient: %v", err) diff --git a/storage/client_repository.go b/storage/client_repository.go index 9d29b55b..2d0ff7ad 100644 --- a/storage/client_repository.go +++ b/storage/client_repository.go @@ -4,7 +4,10 @@ import ( "context" "fmt" "net" + "net/url" "slices" + "strconv" + "strings" "unicode" "unicode/utf8" @@ -30,6 +33,36 @@ const ( SenderConstrainMTLS ) +// ApplicationType is what kind of application a client is, which +// decides the redirect URIs it may use (OpenID Connect Dynamic Client +// Registration 1.0 §2's application_type; RFC 8252 §8.4 asks a server +// to record it). +type ApplicationType uint8 + +const ( + // ApplicationTypeWeb is a web application — the zero value, so every + // client registered before this field keeps behaving as it did. Its + // redirect URIs are https, plus loopback http under development + // assurance. + ApplicationTypeWeb ApplicationType = iota + + // ApplicationTypeNative is a native app — a mobile or desktop + // wallet, say — which receives its authorization response at one of + // RFC 8252's native-app redirect URIs, in production as well: + // + // - a private-use URI scheme (RFC 8252 §7.1), a domain name in + // reverse order: com.example.app:/callback; + // - loopback http (§7.3) to the IP literal 127.0.0.1 or [::1], never + // "localhost" (§8.3), matched on any port at request time; + // - a claimed https URI (§7.2), as for a web application. + // + // NewRegisteredClient refuses a native client's redirect URI of any + // other form. A native client still authenticates as every client + // here does: give each app instance credentials of its own, as + // attestation-based client authentication does. + ApplicationTypeNative +) + // ClientAuthMethod is the closed set of mechanisms a registered client // authenticates itself to this server with. type ClientAuthMethod uint8 @@ -156,6 +189,7 @@ const ( type RegisteredClient struct { id fapi.ClientID redirectURIs []fapi.RegisteredRedirectURI + applicationType ApplicationType clientAssertionAlgorithm fapi.SignatureAlgorithm clientAssertionAlgorithms []fapi.SignatureAlgorithm requestObjectAlgorithm fapi.SignatureAlgorithm @@ -201,6 +235,11 @@ type RegisteredClientConfig struct { // RegisteredClient.AllowsAuthorizationCodeGrant). RedirectURIs []fapi.RegisteredRedirectURI + // ApplicationType is ApplicationTypeWeb (the zero value) or + // ApplicationTypeNative, which admits a native app's redirect URIs — + // see ApplicationTypeNative. + ApplicationType ApplicationType + // ClientAuthMethod selects how this client authenticates — // ClientAuthMethodPrivateKeyJWT (the zero value/default), // ClientAuthMethodSelfSignedTLSClientAuth, or @@ -517,12 +556,16 @@ func NewRegisteredClient(cfg RegisteredClientConfig) (RegisteredClient, error) { return RegisteredClient{}, err } + if err := checkApplicationType(cfg); err != nil { + return RegisteredClient{}, err + } redirectURIs := make([]fapi.RegisteredRedirectURI, len(cfg.RedirectURIs)) copy(redirectURIs, cfg.RedirectURIs) return RegisteredClient{ id: cfg.ID, redirectURIs: redirectURIs, + applicationType: cfg.ApplicationType, clientAssertionAlgorithm: primaryAlg, clientAssertionAlgorithms: algs, requestObjectAlgorithm: cfg.RequestObjectAlgorithm, @@ -754,10 +797,90 @@ func (c RegisteredClient) HasRedirectURI(candidate string) bool { if u.Equal(candidate) { return true } + // RFC 8252 §7.3, §8.4: a native app's loopback redirect URI + // matches exactly except for the port, which the app picks when + // it starts listening. + if c.applicationType == ApplicationTypeNative && loopbackMatchesAnyPort(string(u), candidate) { + return true + } } return false } +// ApplicationType returns whether this client is a web application or a +// native app — see RegisteredClientConfig.ApplicationType. +func (c RegisteredClient) ApplicationType() ApplicationType { return c.applicationType } + +// checkApplicationType refuses an unknown application type, and a native +// client's redirect URI that isn't one of the forms ApplicationTypeNative +// lists. +func checkApplicationType(cfg RegisteredClientConfig) error { + switch cfg.ApplicationType { + case ApplicationTypeWeb: + return nil + case ApplicationTypeNative: + default: + return fmt.Errorf("storage: client %q has an unknown application type %d", cfg.ID, cfg.ApplicationType) + } + for _, uri := range cfg.RedirectURIs { + parsed, err := fapi.ParseRedirectURL(string(uri), fapi.AllowLoopbackHTTP(), fapi.AllowPrivateUseScheme()) + if err != nil { + return fmt.Errorf("storage: client %q: native redirect URI %q: %w", cfg.ID, uri, err) + } + if u := parsed.URL(); u.Scheme == "http" { + if !isLoopbackLiteral(u.Hostname()) { + return fmt.Errorf("storage: client %q: native redirect URI %q: loopback redirects use the IP literal 127.0.0.1 or [::1], not a name (RFC 8252 §8.3)", cfg.ID, uri) + } + } + } + return nil +} + +// isLoopbackLiteral reports whether host is the IP literal 127.0.0.1 or +// ::1, the loopback addresses RFC 8252 §7.3 names. +func isLoopbackLiteral(host string) bool { + ip := net.ParseIP(host) + return ip != nil && (ip.Equal(net.IPv4(127, 0, 0, 1)) || ip.Equal(net.IPv6loopback)) +} + +// loopbackMatchesAnyPort reports whether candidate is the loopback http +// redirect URI registered, but for its port: the same string once the +// port is taken out of each, and a candidate port, if any, between 1 and +// 65535. +func loopbackMatchesAnyPort(registered, candidate string) bool { + r, ok := withoutLoopbackPort(registered) + if !ok { + return false + } + c, ok := withoutLoopbackPort(candidate) + return ok && r == c +} + +// withoutLoopbackPort returns uri without its port, if uri is an http +// URI to a loopback IP literal, spelled exactly "http://". +func withoutLoopbackPort(uri string) (string, bool) { + const prefix = "http://" + if !strings.HasPrefix(uri, prefix) { + return "", false + } + u, err := url.Parse(uri) + if err != nil || u.User != nil || u.Fragment != "" || !strings.HasPrefix(uri[len(prefix):], u.Host) { + return "", false + } + if ip := net.ParseIP(u.Hostname()); ip == nil || !ip.IsLoopback() { + return "", false + } + host := u.Host + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", false + } + host = strings.TrimSuffix(host, ":"+port) + } + return prefix + host + uri[len(prefix)+len(u.Host):], true +} + // ClientAssertionAlgorithm returns the first of the algorithms this // client's client assertions may be signed with — the only one, unless // it was registered with ClientAssertionAlgorithms. See diff --git a/storage/native_app_test.go b/storage/native_app_test.go new file mode 100644 index 00000000..79e47c7e --- /dev/null +++ b/storage/native_app_test.go @@ -0,0 +1,79 @@ +package storage + +import ( + "testing" + + fapi "github.com/idfoundry/fapigo" +) + +func nativeClient(t *testing.T, uris ...fapi.RegisteredRedirectURI) (RegisteredClient, error) { + t.Helper() + return NewRegisteredClient(RegisteredClientConfig{ + ID: "wallet", RedirectURIs: uris, ClientAssertionAlgorithm: fapi.ES256, ApplicationType: ApplicationTypeNative, + }) +} + +func TestNativeRedirectURIsAtRegistration(t *testing.T) { + for uri, want := range map[fapi.RegisteredRedirectURI]bool{ + "org.idfoundry.oid4vcgo.demowallet:/callback": true, + "http://127.0.0.1/callback": true, + "http://[::1]/callback": true, + "http://127.0.0.1:8400/callback": true, + "https://wallet.example/callback": true, + "myapp:/callback": false, // RFC 8252 §8.4: no "." + "http://localhost/callback": false, // §8.3: not a name + "http://127.0.0.2/callback": false, + "http://wallet.example/callback": false, + "com.example.app://host/callback": false, + } { + _, err := nativeClient(t, uri) + if got := err == nil; got != want { + t.Errorf("native client with %q: %v, want accepted %v", uri, err, want) + } + } + if _, err := NewRegisteredClient(RegisteredClientConfig{ + ID: "c", RedirectURIs: []fapi.RegisteredRedirectURI{"https://rp.example/cb"}, ClientAssertionAlgorithm: fapi.ES256, ApplicationType: 9, + }); err == nil { + t.Error("NewRegisteredClient accepted an unknown application type") + } +} + +// TestNativeLoopbackMatchesAnyPort covers RFC 8252 §7.3 and §8.4: a +// native client's loopback redirect URI matches on any port, and +// exactly otherwise; a web client's still matches exactly. +func TestNativeLoopbackMatchesAnyPort(t *testing.T) { + native, err := nativeClient(t, "http://127.0.0.1/callback", "http://[::1]:8400/callback") + if err != nil { + t.Fatal(err) + } + for candidate, want := range map[string]bool{ + "http://127.0.0.1/callback": true, + "http://127.0.0.1:51004/callback": true, + "http://[::1]:61023/callback": true, + "http://[::1]/callback": true, + "http://127.0.0.1:51004/callback2": false, + "http://127.0.0.1:51004/callback?x": false, + "http://127.0.0.1:0/callback": false, + "http://127.0.0.1:70000/callback": false, + "http://localhost:51004/callback": false, + "HTTP://127.0.0.1:51004/callback": false, + "http://u@127.0.0.1:51004/callback": false, + "https://127.0.0.1:51004/callback": false, + } { + if got := native.HasRedirectURI(candidate); got != want { + t.Errorf("native HasRedirectURI(%q) = %v, want %v", candidate, got, want) + } + } + web, err := NewRegisteredClient(RegisteredClientConfig{ + ID: "web", RedirectURIs: []fapi.RegisteredRedirectURI{"http://127.0.0.1/callback"}, ClientAssertionAlgorithm: fapi.ES256, + }) + if err != nil { + t.Fatal(err) + } + if web.HasRedirectURI("http://127.0.0.1:51004/callback") { + t.Error("a web client's loopback redirect URI matched on another port") + } + if web.ApplicationType() != ApplicationTypeWeb || native.ApplicationType() != ApplicationTypeNative { + t.Error("ApplicationType() doesn't report the registration") + } +} diff --git a/url.go b/url.go index 2c8251a8..a68f3442 100644 --- a/url.go +++ b/url.go @@ -8,10 +8,11 @@ import ( "strings" ) -// URL is a validated, security-sensitive URL — an issuer identifier or -// an endpoint. It can only be constructed via ParseIssuerURL or -// ParseEndpointURL, which enforce: absolute, HTTPS (except an -// explicitly enabled loopback development exception), no embedded +// URL is a validated, security-sensitive URL — an issuer identifier, an +// endpoint or a redirect destination. It can only be constructed via +// ParseIssuerURL, ParseEndpointURL or ParseRedirectURL, which enforce: +// absolute, HTTPS (except an explicitly enabled loopback exception, or a +// native app's private-use scheme for a redirect), no embedded // credentials, no fragment, and a normalized (lowercased) scheme and // host. type URL struct { @@ -19,7 +20,8 @@ type URL struct { } type urlOptions struct { - allowLoopbackHTTP bool + allowLoopbackHTTP bool + allowPrivateUseScheme bool } // URLOption configures ParseIssuerURL or ParseEndpointURL. @@ -33,6 +35,84 @@ func AllowLoopbackHTTP() URLOption { return func(o *urlOptions) { o.allowLoopbackHTTP = true } } +// AllowPrivateUseScheme permits, for ParseRedirectURL only, a native +// app's private-use URI scheme redirect (RFC 8252 §7.1), such as +// "com.example.app:/oauth2redirect": a scheme that is a domain name in +// reverse order, so it contains a ".", followed by a path and no +// authority, as RFC 8252 §7.1 writes it. Enable it only for a client +// registered as a native app. +func AllowPrivateUseScheme() URLOption { + return func(o *urlOptions) { o.allowPrivateUseScheme = true } +} + +// ParseRedirectURL parses and validates raw as an OAuth redirect URI: +// https, like ParseEndpointURL, unless an option admits loopback http +// (AllowLoopbackHTTP) or a native app's private-use scheme +// (AllowPrivateUseScheme). +func ParseRedirectURL(raw string, opts ...URLOption) (URL, error) { + var o urlOptions + for _, opt := range opts { + opt(&o) + } + if o.allowPrivateUseScheme { + if parsed, err := url.Parse(raw); err == nil && parsed.Scheme != "http" && parsed.Scheme != "https" && parsed.Scheme != "" { + u, err := parsePrivateUseURL(parsed) + if err != nil { + return URL{}, fmt.Errorf("fapi: parse redirect URL: %w", err) + } + return u, nil + } + } + u, err := parseSecureURL(raw, opts) + if err != nil { + return URL{}, fmt.Errorf("fapi: parse redirect URL: %w", err) + } + return u, nil +} + +// parsePrivateUseURL validates parsed as a private-use URI scheme +// redirect (RFC 8252 §7.1, §8.4). +func parsePrivateUseURL(parsed *url.URL) (URL, error) { + if !isReverseDomainScheme(parsed.Scheme) { + return URL{}, fmt.Errorf("private-use scheme %q must be a reverse-order domain name, such as com.example.app", parsed.Scheme) + } + if parsed.Opaque != "" || parsed.Host != "" || parsed.User != nil || !strings.HasPrefix(parsed.Path, "/") { + return URL{}, fmt.Errorf("private-use redirect URI must be scheme:/path, with no authority") + } + if parsed.Fragment != "" { + return URL{}, fmt.Errorf("URL must not contain a fragment") + } + return URL{value: *parsed}, nil +} + +// isReverseDomainScheme reports whether scheme has the form RFC 8252 +// §7.1 requires of a private-use URI scheme: a domain name in reverse +// order, at least two dot-separated labels, each of letters, digits and +// "-", none empty. A scheme with no "." is the one RFC 8252 §8.4 says to +// reject at a minimum. +func isReverseDomainScheme(scheme string) bool { + labels := strings.Split(scheme, ".") + if len(labels) < 2 { + return false + } + for _, label := range labels { + if label == "" || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, r := range label { + if !isLabelRune(r) { + return false + } + } + } + return true +} + +// isLabelRune reports whether r may appear in a domain-name label. +func isLabelRune(r rune) bool { + return 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || r == '-' +} + // ParseIssuerURL parses and validates raw as an issuer identifier. func ParseIssuerURL(raw string, opts ...URLOption) (URL, error) { u, err := parseSecureURL(raw, opts)