Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions federation/relying_party_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions redirect_url_test.go
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 28 in redirect_url_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unnecessary variable declaration and use the expression directly in the condition.

See more on https://sonarcloud.io/project/issues?id=IDFoundry_FAPIgo&issues=AaD9ijWWZgIaNUxn5u-o&open=AaD9ijWWZgIaNUxn5u-o&pullRequest=524
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)
}
}
})
}
35 changes: 23 additions & 12 deletions server/complete_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
121 changes: 121 additions & 0 deletions server/native_redirect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion server/par.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
8 changes: 8 additions & 0 deletions server/par_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading