Skip to content

feat(server): accept native-app redirect URIs for clients registered as native - #524

Merged
osanderson merged 1 commit into
mainfrom
feat/server-native-app-redirects
Oct 2, 2026
Merged

osanderson merged 1 commit into
mainfrom
feat/server-native-app-redirects

Conversation

@osanderson

Copy link
Copy Markdown
Collaborator

Summary

A mobile wallet's redirect URI, such as org.idfoundry.oid4vcgo.demowallet:/callback (a private-use URI scheme, RFC 8252 §7.1), was refused at PAR with "redirect_uri is not an acceptable redirect destination", whatever its registration said. Server.parseRedirectURI admitted https, plus loopback http under development assurance only, and fapi.ParseEndpointURL couldn't even represent a URI with no host.

FAPI 2.0 forbids only non-loopback http redirect URIs; it allows loopback http for native clients and doesn't forbid private-use schemes. RFC 8252 §8.4 says the server MUST record the client type, match exactly except for the port on loopback, and SHOULD require reverse-domain schemes, at minimum rejecting one with no ".". This adds that, per client:

  • storage.RegisteredClientConfig.ApplicationType: ApplicationTypeWeb is the zero value and today's behaviour. ApplicationTypeNative follows OIDC Registration's application_type.

  • A native client's redirect URIs are checked at registration (NewRegisteredClient). Each must be one of:

    • a private-use scheme in reverse-domain form, containing a ".", written scheme:/path with no authority (RFC 8252 §7.1);
    • loopback http to the literal 127.0.0.1 or [::1], never localhost (§8.3);
    • https (claimed URIs, §7.2).

    Anything else is refused at registration, not at the first PAR.

  • Matching: a native client's loopback URI matches on any port, and exactly otherwise (§7.3, §8.4). Register http://127.0.0.1/callback, then request http://127.0.0.1:51004/callback. The token request still has to name the same redirect_uri, port included (RFC 6749 §4.1.3).

  • PAR accepts all three for a native client in production too. A web client is unchanged: https, plus exact-match loopback http under development assurance only.

  • fapi.ParseRedirectURL and fapi.AllowPrivateUseScheme() represent a redirect destination with no host. ParseEndpointURL doesn't accept private-use schemes, even with the option.

  • Federated clients stay web. An RP's self-published application_type isn't acted on, for the same reason its scopes aren't. The field's doc says so.

Unchanged:

  • Native clients still authenticate. FAPIgo has no public clients; RFC 8252 treats a native app as public unless each instance has its own credentials, which attestation-based client authentication provides.
  • PKCE, DPoP and PAR still apply, which is what makes a private-use scheme, claimable by any app, acceptable here.

Docs. ApplicationTypeNative's doc, GETTING_STARTED's client registration section, and federation.RelyingPartyMetadata.ApplicationType. Additive (feat).

Tests

  • fapi:
    • TestParseRedirectURL:
      • accepted: https, the wallet's URI, a query, a hyphenated label, and loopback with the option;
      • refused: myapp:/cb (no "."), an opaque form, an authority, an empty or leading-hyphen label, a fragment, non-loopback http;
      • without options it accepts https only, and ParseEndpointURL ignores the option.
    • FuzzParseRedirectURL: never panics, and with no options accepts only https (20-second run, clean).
  • storage:
    • TestNativeRedirectURIsAtRegistration: private-use, 127.0.0.1, [::1], a port and https are accepted. myapp:, localhost, 127.0.0.2, non-loopback http and an authority form are refused. So is an unknown application type.
    • TestNativeLoopbackMatchesAnyPort: any port matches, for IPv4 and IPv6. A different path or query, port 0 or 70000, localhost, HTTP://, userinfo, or https does not. A web client's loopback URI doesn't match on another port.
  • server:
    • TestNativeClientPrivateUseRedirectUnderProduction: the response goes to the private-use URI with code, state and iss.
    • TestNativeClientLoopbackAnyPortUnderProduction: the response goes to the requested port. The token exchange refuses the port-less registered URI and accepts the requested one.
    • TestWebClientRefusedNativeRedirects: a web client gets invalid_request for a private-use URI, loopback on any port, and loopback as registered, all under production.
    • TestNativeClientPrivateUseRedirectWithJARM: a signed response (JARM) is sent to a private-use URI.
  • Mutation checks: each of these fails a test:
    • disabling the native branch;
    • dropping the any-port match, or applying it to web clients;
    • allowing localhost or any loopback IP;
    • accepting any scheme, or an authority form;
    • skipping the port-range check.
  • Other checks:
    • go test -race across fapi, storage, server, federation and client passes.
    • go test ./cmd/... ./fapitest/... passes.
    • Every demo's tests pass.
    • golangci-lint is clean.

🤖 Generated with Claude Code

…as native

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 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.55556% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
storage/client_repository.go 95.65% 1 Missing and 1 partial ⚠️
url.go 94.59% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@sonarqubecloud

sonarqubecloud Bot commented Oct 2, 2026

Copy link
Copy Markdown

@osanderson
osanderson merged commit d0fae2c into main Oct 2, 2026
17 checks passed
@osanderson
osanderson deleted the feat/server-native-app-redirects branch October 2, 2026 16:59
osanderson added a commit that referenced this pull request Oct 3, 2026
…irect policy

#524 parsed every authorization response permissively at build time, on
the grounds that the pushed authorization request had already checked
redirect_uri against the client's type. BuildAuthorizationErrorRedirect
never goes through one: it checked only that the URI was registered. So
under production assurance a web client registered for a loopback or
private-use URI got an error redirect there, which FAPI 2.0 forbids and
v0.43.0 refused. It now applies parseRedirectURI as PAR does.

PAR's refusal of a native app's redirect URI on a client registered as
web now names ApplicationTypeNative in its cause, for the logs: the
first mistake a wallet's integrator makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant