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
265 changes: 246 additions & 19 deletions docs/guides/login.md

Large diffs are not rendered by default.

40 changes: 29 additions & 11 deletions docs/research/asgardeo-redirect-uri-and-scope-narrowing.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,19 +242,37 @@ negative finding, and nothing in the shell should be designed as though it were.

| Question | Confirmed (Asgardeo docs) | Inferred (WSO2 IS only) | Empirical verdict |
|---|---|---|---|
| Fixed-port loopback (`127.0.0.1:<port>`) registrable | `localhost:<port>` proven registrable via quickstart; REST schema imposes no blocking restriction | — | **Pending live run.** Any successful `make smoke-login` answers this incidentally: the walkthrough registers the literal `127.0.0.1` form on all four ports and the login binds one of them. Record: verdict, date, deployment. |
| Any-port loopback (RFC 8252 §7.3) | Not documented at all | IS 6.0.0+: exact port match waived for loopback IPs | **Pending live run.** `make empirical-asgardeo`, experiment A. Record the `ASGARDEO ANY-PORT LOOPBACK: {supported\|rejected}` verdict, its date, and the `deployment:` line the run printed under it. |
| Fixed-port loopback (`127.0.0.1:<port>`) registrable | `localhost:<port>` proven registrable via quickstart; REST schema imposes no blocking restriction | — | **Registrable. 2026-08-06**, deployment `https://api.asgardeo.io/t/kanushka/oauth2/token`. Answered incidentally by a passing `make smoke-login`: all four `127.0.0.1` callback URLs were registered literally and the login bound and returned to `127.0.0.1:10425`. |
| Any-port loopback (RFC 8252 §7.3) | Not documented at all | IS 6.0.0+: exact port match waived for loopback IPs | **Supported. 2026-08-06**, deployment `https://api.asgardeo.io/t/kanushka/oauth2/token`. `make empirical-asgardeo` experiment A completed a login through `127.0.0.1:16000`, a port the application does not register, so Asgardeo waives the port when matching loopback redirect URIs as RFC 8252 §7.3 asks. Matches the IS 6.0.0+ inference. |
| Redirect URI validation rules | Exact match by default; `regexp=(url1\|url2)` prefix for OR-ing multiple exact URLs | Regex support IS-version-gated (5.2.0+); loopback flexibility IS-version-gated (6.0.0+) | **Not measured, and no experiment planned.** The open part is whether a true single-URL wildcard syntax exists, and an experiment can only ever fail to find one — absence of a syntax is not observable by trying one. This stays a documentation question. |
| Refresh-grant scope narrowing | Docs show no `scope` param on refresh_token grant at all (asymmetric vs. client_credentials/password sections, which do show one) | `RefreshGrantHandler.validateScope()`: subset requests honored and narrow the token; over-broad requests rejected with `invalid_scope`; omitted scope keeps full original grant | **Pending live run.** `make empirical-asgardeo`, experiment B. Record the `ASGARDEO REFRESH NARROWING: {honored\|ignored\|rejected}` verdict, its date, and the `deployment:` line. |
| Refresh-grant scope narrowing | Docs show no `scope` param on refresh_token grant at all (asymmetric vs. client_credentials/password sections, which do show one) | `RefreshGrantHandler.validateScope()`: subset requests honored and narrow the token; over-broad requests rejected with `invalid_scope`; omitted scope keeps full original grant | **Honored. 2026-08-06**, deployment `https://api.asgardeo.io/t/kanushka/oauth2/token`. `make empirical-asgardeo` experiment B established a session for `reference:status:read reference:status:write`, then ran a refresh grant for `reference:status:read` alone and received exactly that one permission — not the plain verdict's qualified form, so no protocol scopes were retained either. Matches the IS-source inference. |

Both questions remain genuinely open for Asgardeo specifically; the WSO2 IS
evidence is suggestive (shared codebase lineage, per the parent document's
landscape findings) but not a substitute for a live test against an Asgardeo
tenant. The broker decision does not assume Asgardeo parity with IS on either
point: the shell verifies the narrowing it asked for and refuses
(`auth.narrowing_unavailable`) when it cannot prove it, which is the behavior
that is correct under every one of the three possible verdicts rather than the
behavior that bets on one.
Both questions are now answered for Asgardeo, and both answers match what the
WSO2 IS source suggested — the shared codebase lineage held. Measured
2026-08-06 against `https://api.asgardeo.io/t/kanushka/oauth2/token`; the
verdicts are per-deployment, so a second tenant is not covered by these cells.

The broker decision never assumed that parity: the shell verifies the narrowing
it asked for and refuses (`auth.narrowing_unavailable`) when it cannot prove it,
which is correct under every one of the three possible verdicts rather than the
behavior that bets on one. The favorable verdict does not change that design; it
means the refusal path is now the exceptional one on Asgardeo rather than the
expected one.

**A third finding, not anticipated by this document.** The same runs established
that Asgardeo binds a JWT access token's `aud` claim to the **client ID**, never
to the API resource identifier whose scopes the token carries. An access token
issued for `reference:status:read reference:status:write` against an application
authorized for the `reference-status` API resource carried
`"aud": "<client id>"` and nothing else. Asgardeo exposes an Audience field only
under **ID Token** in the application's Protocol tab; there is no equivalent for
access tokens, because the value is not configurable. The consequence for broker
policy is direct: on Asgardeo the only value that can satisfy the shell's
audience check is the client ID, so `products.<namespace>.audience` cannot carry
product-level meaning there, and the check cannot distinguish a token brokered
for one namespace from one brokered for another. Whether Identity Server 7.x
behaves the same way is unmeasured and is the open question this finding
replaces the previous two with.

## 4. Producing and recording the verdicts

Expand Down
93 changes: 91 additions & 2 deletions internal/auth/fakeissuer/fakeissuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ import (
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -90,6 +94,15 @@ type Options struct {
// OmitRefreshToken answers the authorization code grant without a refresh
// token, modeling an application that was never granted offline access.
OmitRefreshToken bool
// NegativeSerialCertificate publishes an x5c certificate chain on the JWKS
// key whose certificate carries a negative serial number: forbidden by RFC
// 5280 section 4.1.2.2, emitted by WSO2 deployments for years, and rejected
// outright by Go's x509 parser since 1.23.
//
// The signing key itself stays valid — n and e are untouched — so this
// models the real failure exactly: a deployment whose keys can verify a
// token perfectly well, behind a certificate that nothing needs to read.
NegativeSerialCertificate bool
}

// Issuer is one running fake issuer. Its URL doubles as the issuer identifier.
Expand All @@ -100,6 +113,9 @@ type Issuer struct {
key *rsa.PrivateKey
keyID string
client *http.Client
// certificate is the DER published in the key's x5c chain, empty unless a
// test asked for one.
certificate []byte

mutex sync.Mutex
codes map[string]codeGrant
Expand Down Expand Up @@ -142,6 +158,9 @@ func New(t *testing.T, opts Options) *Issuer {
refreshTokens: map[string][]string{},
accessTokens: map[string]tokenRecord{},
}
if opts.NegativeSerialCertificate {
issuer.certificate = negativeSerialCertificate(t, key)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", issuer.handleDiscovery)
mux.HandleFunc("GET /jwks", issuer.handleJWKS)
Expand Down Expand Up @@ -210,9 +229,79 @@ func (i *Issuer) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
}

func (i *Issuer) handleJWKS(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{
key := jose.JSONWebKey{
Key: i.key.Public(), KeyID: i.keyID, Algorithm: string(jose.RS256), Use: "sig",
}}})
}
if len(i.certificate) == 0 {
writeJSON(w, http.StatusOK, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{key}})
return
}
// The chain is attached after go-jose has rendered the key, because
// go-jose cannot marshal a certificate it would refuse to parse — which is
// the entire point of this fixture.
rendered, err := key.MarshalJSON()
if err != nil {
http.Error(w, "fakeissuer: render key: "+err.Error(), http.StatusInternalServerError)
return
}
var members map[string]json.RawMessage
if err := json.Unmarshal(rendered, &members); err != nil {
http.Error(w, "fakeissuer: reread key: "+err.Error(), http.StatusInternalServerError)
return
}
chain, err := json.Marshal([]string{base64.StdEncoding.EncodeToString(i.certificate)})
if err != nil {
http.Error(w, "fakeissuer: render chain: "+err.Error(), http.StatusInternalServerError)
return
}
members["x5c"] = chain
writeJSON(w, http.StatusOK, map[string]any{"keys": []any{members}})
}

// negativeSerialCertificate returns a self-signed certificate for key whose
// serial number is negative.
//
// It cannot be minted directly: crypto/x509.CreateCertificate refuses a
// negative serial ("serial number must be positive"), which is why the value
// is edited into the encoding afterwards. A serial whose leading value byte
// has its high bit set is encoded by Go with a 0x00 pad to keep it positive;
// removing that pad reinterprets the same four bytes as a negative
// two's-complement integer, which is precisely the encoding real deployments
// publish. Removing a byte shortens the two enclosing SEQUENCEs by one each.
func negativeSerialCertificate(t *testing.T, key *rsa.PrivateKey) []byte {
t.Helper()
const padded = "\x02\x05\x00\xc5\xb0\x7c\x97" // INTEGER, 5 bytes, positive
const negative = "\x02\x04\xc5\xb0\x7c\x97" // INTEGER, 4 bytes, negative
template := &x509.Certificate{
SerialNumber: big.NewInt(0xC5B07C97),
Subject: pkix.Name{CommonName: "fakeissuer negative serial"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("fakeissuer: create certificate: %v", err)
}
at := strings.Index(string(der), padded)
if at < 0 {
t.Fatal("fakeissuer: the serial number is not encoded where this fixture expects it")
}
edited := make([]byte, 0, len(der)-1)
edited = append(edited, der[:at]...)
edited = append(edited, negative...)
edited = append(edited, der[at+len(padded):]...)
// Both the Certificate and the TBSCertificate SEQUENCE use a long-form
// two-byte length, and each now describes one byte less.
for _, offset := range []int{2, 6} {
if edited[offset-2] != 0x30 || edited[offset-1] != 0x82 {
t.Fatalf("fakeissuer: unexpected DER header at offset %d", offset-2)
}
binary.BigEndian.PutUint16(edited[offset:], binary.BigEndian.Uint16(edited[offset:])-1)
}
if _, err := x509.ParseCertificate(edited); err == nil {
t.Fatal("fakeissuer: the certificate this fixture exists to make unparseable parses")
}
return edited
}

// handleAuthorize auto-approves: the "user" always consents, so a test's whole
Expand Down
151 changes: 151 additions & 0 deletions internal/auth/oauthflow/jwks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package oauthflow

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
)

// certificateFields are the members of a JSON Web Key that describe an X.509
// certificate rather than the key itself. x5t and x5t#S256 are thumbprints of
// the chain in x5c, and go-jose checks them against it, so they leave together
// or not at all.
var certificateFields = []string{"x5c", "x5t", "x5t#S256"}

// keyParameters are the members that fully define a public key, by key type.
// A key carrying its own parameters needs nothing from a certificate.
var keyParameters = map[string][]string{
"RSA": {"n", "e"},
"EC": {"crv", "x", "y"},
"OKP": {"crv", "x"},
}

// withoutCertificates drops the certificate members from every self-describing
// key in a JSON Web Key Set, and reports whether it changed anything.
//
// This exists because go-jose parses x5c eagerly while unmarshalling a key set
// and fails the whole document when any certificate in it does not parse. Go
// has rejected certificates with negative serial numbers since 1.23, and WSO2
// deployments have published them for years — so an issuer whose keys can
// verify a token perfectly well becomes an issuer with no readable keys at
// all, over a field the verification never needed.
//
// Only keys that already carry their own parameters are stripped. A key that
// somehow depended on its certificate keeps it and fails loudly, which is the
// right outcome: this removes a spurious failure, it does not paper over a key
// the shell genuinely cannot read.
func withoutCertificates(body []byte) ([]byte, bool) {
var document map[string]json.RawMessage
if err := json.Unmarshal(body, &document); err != nil {
return nil, false
}
rawKeys, present := document["keys"]
if !present {
return nil, false
}
var keys []map[string]json.RawMessage
if err := json.Unmarshal(rawKeys, &keys); err != nil {
return nil, false
}
stripped := false
for _, key := range keys {
if !selfDescribing(key) {
continue
}
for _, field := range certificateFields {
if _, carried := key[field]; carried {
delete(key, field)
stripped = true
}
}
}
if !stripped {
return nil, false
}
rewrittenKeys, err := json.Marshal(keys)
if err != nil {
return nil, false
}
document["keys"] = rewrittenKeys
rewritten, err := json.Marshal(document)
if err != nil {
return nil, false
}
return rewritten, true
}

// selfDescribing reports whether a key carries every parameter its type needs,
// so that discarding its certificate loses nothing.
func selfDescribing(key map[string]json.RawMessage) bool {
var keyType string
if err := json.Unmarshal(key["kty"], &keyType); err != nil {
return false
}
required, known := keyParameters[keyType]
if !known {
return false
}
for _, parameter := range required {
if _, carried := key[parameter]; !carried {
return false
}
}
return true
}

// certificateStripper removes certificate members from key sets on their way
// back from the issuer, before any library parses them.
//
// It sits at the transport because that is the one place every fetch the OIDC
// library makes passes through, including the key set it fetches lazily on
// first verification. Responses that are not key sets are returned exactly as
// they arrived, byte for byte.
type certificateStripper struct{ base http.RoundTripper }

func (s certificateStripper) RoundTrip(request *http.Request) (*http.Response, error) {
base := s.base
if base == nil {
base = http.DefaultTransport
}
response, err := base.RoundTrip(request)
if err != nil || response.Body == nil || response.StatusCode != http.StatusOK {
return response, err
}
body, err := io.ReadAll(response.Body)
// Closing is what releases the connection, and a failure to do so says
// nothing about the bytes already read. Refusing the response over it
// would invent exactly the kind of spurious failure this file exists to
// remove. A read that did not finish is different: the body has been
// consumed and cannot be handed on, so there is no response left to
// return.
_ = response.Body.Close()
if err != nil {
return nil, err
}
stripped, changed := withoutCertificates(body)
if !changed {
stripped = body
}
response.Body = io.NopCloser(bytes.NewReader(stripped))
response.ContentLength = int64(len(stripped))
response.Header.Set("Content-Length", strconv.Itoa(len(stripped)))
return response, nil
}
Loading