From afb5021336ca0649ed36ac133f5091b97969f192 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 19:54:02 +0530 Subject: [PATCH 1/7] feat(fakeissuer): serve the device authorization grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic issuer gains a device authorization endpoint, advertises it and the device-code grant in its discovery document, and models the behaviours a device login has to survive: an approval that arrives after several pending polls, a refusal, an expired code, a deployment that asks the client to slow down, and a deployment that offers no device grant at all. Every poll is timestamped. That record is what lets a test assert the shell honoured the interval it was given and backed off when told to — a property that is only real from the deployment's side, and that a shell merely holding the right number in a variable would not satisfy. Refs #42 --- internal/auth/fakeissuer/fakeissuer.go | 266 ++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 1 deletion(-) diff --git a/internal/auth/fakeissuer/fakeissuer.go b/internal/auth/fakeissuer/fakeissuer.go index d7c25bf..e0d564e 100644 --- a/internal/auth/fakeissuer/fakeissuer.go +++ b/internal/auth/fakeissuer/fakeissuer.go @@ -103,6 +103,42 @@ type Options struct { // 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 + + // DeviceOutcome is how a device authorization ends once the pending and + // slow-down answers below are exhausted: "approve" issues tokens, "deny" + // answers access_denied, "expire" answers expired_token. The default is + // "approve". + DeviceOutcome string + // DevicePendingPolls is how many polls answer authorization_pending before + // DeviceOutcome is applied. The default is zero, so the first poll settles + // the flow — a test that cares about the waiting states asks for them. + DevicePendingPolls int + // DeviceSlowDownPolls is how many polls answer slow_down. They are served + // before the pending ones, so a test can ask for both and know which + // arrives first. + DeviceSlowDownPolls int + // DeviceInterval is the polling interval the device authorization response + // advertises, in seconds. Zero leaves the member out entirely, which is how + // a test reaches the client's own default. + DeviceInterval int + // DeviceExpiresIn is the lifetime the device authorization response + // advertises, in seconds. The default is 600, which is the order of + // magnitude real deployments publish. + DeviceExpiresIn int + // OmitDeviceEndpoint leaves device_authorization_endpoint and the device + // grant out of the discovery document, modeling a deployment that does not + // serve the grant at all. Thunder is such a deployment today. + OmitDeviceEndpoint bool + // OmitDeviceVerificationURIComplete leaves verification_uri_complete out of + // the device authorization response, modeling the many deployments that + // publish only the code and the plain URI. RFC 8628 makes the member + // optional, so a client may not depend on it. + OmitDeviceVerificationURIComplete bool + // OmitDeviceIDToken answers the device grant without an identity token. + // + // Whether Asgardeo and Identity Server return one from this grant is not + // measured, so both answers are modeled rather than assumed. See issue #42. + OmitDeviceIDToken bool } // Issuer is one running fake issuer. Its URL doubles as the issuer identifier. @@ -121,6 +157,8 @@ type Issuer struct { codes map[string]codeGrant refreshTokens map[string][]string // refresh token -> granted scopes accessTokens map[string]tokenRecord // access token -> introspectable facts + deviceGrants map[string]*deviceGrant + devicePolls []time.Time } type codeGrant struct { @@ -131,6 +169,18 @@ type codeGrant struct { clientID string } +// deviceGrant is one device authorization awaiting approval. +type deviceGrant struct { + userCode string + scopes []string + clientID string + // pending and slowDown count down the answers still owed before the + // outcome applies. They live on the grant rather than on the issuer so two + // concurrent tests cannot consume each other's waiting states. + pending int + slowDown int +} + type tokenRecord struct { scopes []string audience string @@ -146,6 +196,7 @@ func New(t *testing.T, opts Options) *Issuer { t.Helper() opts.RefreshScopeMode = scopeMode(t, "RefreshScopeMode", opts.RefreshScopeMode) opts.ClientScopeMode = scopeMode(t, "ClientScopeMode", opts.ClientScopeMode) + opts.DeviceOutcome = deviceOutcome(t, opts.DeviceOutcome) key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("fakeissuer: generate signing key: %v", err) @@ -157,6 +208,7 @@ func New(t *testing.T, opts Options) *Issuer { codes: map[string]codeGrant{}, refreshTokens: map[string][]string{}, accessTokens: map[string]tokenRecord{}, + deviceGrants: map[string]*deviceGrant{}, } if opts.NegativeSerialCertificate { issuer.certificate = negativeSerialCertificate(t, key) @@ -167,6 +219,7 @@ func New(t *testing.T, opts Options) *Issuer { mux.HandleFunc("GET /authorize", issuer.handleAuthorize) mux.HandleFunc("POST /token", issuer.handleToken) mux.HandleFunc("POST /introspect", issuer.handleIntrospect) + mux.HandleFunc("POST /device_authorize", issuer.handleDeviceAuthorize) server := httptest.NewServer(mux) t.Cleanup(server.Close) issuer.URL = server.URL @@ -220,11 +273,23 @@ func (i *Issuer) handleDiscovery(w http.ResponseWriter, _ *http.Request) { "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "code_challenge_methods_supported": []string{"S256"}, - "grant_types_supported": []string{"authorization_code", "refresh_token", "client_credentials"}, + "grant_types_supported": []string{ + "authorization_code", "refresh_token", "client_credentials", deviceGrantType, + }, + "device_authorization_endpoint": i.URL + "/device_authorize", } if i.opts.OmitS256 { delete(document, "code_challenge_methods_supported") } + if i.opts.OmitDeviceEndpoint { + // Both go, because a deployment without the grant advertises neither. + // Dropping only the endpoint would model something that does not exist + // and would let a client pass by reading the wrong member. + delete(document, "device_authorization_endpoint") + document["grant_types_supported"] = []string{ + "authorization_code", "refresh_token", "client_credentials", + } + } writeJSON(w, http.StatusOK, document) } @@ -360,6 +425,8 @@ func (i *Issuer) handleToken(w http.ResponseWriter, r *http.Request) { i.refreshGrant(w, r) case "client_credentials": i.clientCredentialsGrant(w, r) + case deviceGrantType: + i.deviceGrant(w, r) default: oauthError(w, http.StatusBadRequest, "unsupported_grant_type") } @@ -502,6 +569,203 @@ func (i *Issuer) clientCredentialsGrant(w http.ResponseWriter, r *http.Request) }) } +// deviceGrantType is RFC 8628's grant type identifier. +const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// defaultDeviceExpiresIn is the device code lifetime this issuer advertises +// when a test states none, in seconds. +const defaultDeviceExpiresIn = 600 + +// handleDeviceAuthorize starts one device authorization. +// +// It mints both codes and records what the eventual approval will be worth. +// Nothing here waits: the waiting states a real deployment produces while a +// human walks to another device are modeled by the counters the token endpoint +// draws down, so a test states the shape of the wait rather than living +// through it. +func (i *Issuer) handleDeviceAuthorize(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + oauthError(w, http.StatusBadRequest, "invalid_request") + return + } + if i.opts.OmitDeviceEndpoint { + // A deployment that does not advertise the grant does not serve it + // either. Answering here anyway would let a client that ignored + // discovery pass a test it should fail. + oauthError(w, http.StatusNotFound, "invalid_request") + return + } + clientID := presentedClientID(r) + if clientID == "" { + oauthError(w, http.StatusBadRequest, "invalid_client") + return + } + deviceCode := randomToken("dc") + userCode := randomUserCode() + i.mutex.Lock() + i.deviceGrants[deviceCode] = &deviceGrant{ + userCode: userCode, + scopes: splitScopes(r.PostForm.Get("scope")), + clientID: clientID, + pending: i.opts.DevicePendingPolls, + slowDown: i.opts.DeviceSlowDownPolls, + } + i.mutex.Unlock() + + expiresIn := i.opts.DeviceExpiresIn + if expiresIn == 0 { + expiresIn = defaultDeviceExpiresIn + } + response := map[string]any{ + "device_code": deviceCode, + "user_code": userCode, + "verification_uri": i.URL + "/device", + "expires_in": expiresIn, + } + if !i.opts.OmitDeviceVerificationURIComplete { + response["verification_uri_complete"] = i.URL + "/device?user_code=" + userCode + } + // A zero interval is left out rather than sent as zero. RFC 8628 gives the + // member a default precisely so a deployment may omit it, and a client that + // reads a missing member as "poll as fast as you like" is a client this + // fixture exists to catch. + if i.opts.DeviceInterval > 0 { + response["interval"] = i.opts.DeviceInterval + } + writeJSON(w, http.StatusOK, response) +} + +// deviceGrant answers one poll of the token endpoint. +// +// Every poll is timestamped before anything else, including the ones that end +// the flow. That record is what lets a test assert the client honored the +// interval it was given and backed off when told to — a property observable +// only from the deployment's side, which is where this fixture stands. +func (i *Issuer) deviceGrant(w http.ResponseWriter, r *http.Request) { + i.mutex.Lock() + i.devicePolls = append(i.devicePolls, time.Now()) + grant, found := i.deviceGrants[r.PostForm.Get("device_code")] + var answer string + if found { + switch { + case grant.slowDown > 0: + grant.slowDown-- + answer = "slow_down" + case grant.pending > 0: + grant.pending-- + answer = "authorization_pending" + case i.opts.DeviceOutcome == "deny": + answer = "access_denied" + case i.opts.DeviceOutcome == "expire": + answer = "expired_token" + } + } + // The grant is read and its counters drawn down in one critical section, so + // two concurrent polls cannot both consume the last pending answer and both + // be approved. + scopes, clientID := []string(nil), "" + if found { + scopes, clientID = grant.scopes, grant.clientID + } + i.mutex.Unlock() + + switch { + case !found: + // An unknown device code is a spent or forged one. RFC 8628 sends the + // client to RFC 6749's invalid_grant for both. + oauthError(w, http.StatusBadRequest, "invalid_grant") + case answer != "": + oauthError(w, http.StatusBadRequest, answer) + default: + i.issueDeviceTokens(w, scopes, clientID) + } +} + +// issueDeviceTokens answers an approved device authorization. +// +// The token set matches what the authorization code grant produces, minus the +// nonce: RFC 8628 defines no nonce parameter, so an identity token here carries +// none and a client that demanded one would be demanding something the flow +// cannot supply. +func (i *Issuer) issueDeviceTokens(w http.ResponseWriter, scopes []string, clientID string) { + refreshToken := randomToken("rt") + i.mutex.Lock() + i.refreshTokens[refreshToken] = scopes + i.mutex.Unlock() + response := map[string]any{ + "access_token": i.mintAccessToken("user-1", scopes), + "token_type": "Bearer", + "expires_in": 300, + "refresh_token": refreshToken, + "scope": strings.Join(scopes, " "), + } + if !i.opts.OmitDeviceIDToken { + response["id_token"] = i.mintIDToken(clientID, "") + } + writeJSON(w, http.StatusOK, response) +} + +// DevicePolls returns when each poll of the device grant arrived, in order. +// A test reads the gaps between them; the absolute times mean nothing. +func (i *Issuer) DevicePolls() []time.Time { + i.mutex.Lock() + defer i.mutex.Unlock() + return append([]time.Time(nil), i.devicePolls...) +} + +// LastDeviceCode is the device code this issuer most recently minted. +// +// It exists for the non-disclosure sweep: the device code is the one value in +// this flow that is exchangeable for a session, and a test cannot prove the +// shell kept it off the terminal without knowing what to look for. +func (i *Issuer) LastDeviceCode() string { + i.mutex.Lock() + defer i.mutex.Unlock() + // One login mints one code, which is every case that has a "most recent". + for code := range i.deviceGrants { + return code + } + return "" +} + +// userCodeAlphabet is RFC 8628 section 6.1's recommended character set: upper +// case, and free of the pairs a person mishears or mistypes — no 0/O, no 1/I. +const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ" + +// randomUserCode mints a code in the WDJB-MJHT shape the RFC uses as its +// example. The separator is part of the value, so a client that strips or +// reformats it fails against this fixture exactly as it would against a +// deployment that expects its own code back. +func randomUserCode() string { + raw := make([]byte, 8) + if _, err := rand.Read(raw); err != nil { + panic(fmt.Sprintf("fakeissuer: random user code: %v", err)) + } + letters := make([]byte, 0, 9) + for at, value := range raw { + if at == 4 { + letters = append(letters, '-') + } + letters = append(letters, userCodeAlphabet[int(value)%len(userCodeAlphabet)]) + } + return string(letters) +} + +// deviceOutcome validates a configured outcome, so a typo in a test's options +// fails the test rather than silently approving the login it meant to refuse. +func deviceOutcome(t *testing.T, configured string) string { + t.Helper() + switch configured { + case "": + return "approve" + case "approve", "deny", "expire": + return configured + default: + t.Fatalf("fakeissuer: DeviceOutcome = %q, want approve, deny, or expire", configured) + return "" + } +} + // presentedClientCredentials reads the client identifier and secret a // confidential client identified itself with. RFC 6749 requires the values to // be form-encoded before they are used as HTTP Basic credentials, so they are From f3a5993117aa1d36a3a8239bee23b8a96ed0662f Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 19:54:21 +0530 Subject: [PATCH 2/7] feat(auth): log in through the device authorization grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wso2 login` could only be finished by someone sitting at a browser on the same machine: the browser mode binds a loopback listener and waits to be redirected back to it, which a developer over SSH or inside a container cannot reach. The `oauth-device` kind was legal configuration that refused at use, so such a user was told the shell knew what they wanted and would not do it. RFC 8628 removes the constraint. The shell prints a short URI and a user code, the approval happens on any other device, and what it leaves behind is the same session a browser login leaves — so nothing downstream changes and no module can tell the two apart. x/oauth2 owns the polling, deliberately: it already implements the rule this most needs right, which is to poll no faster than the deployment asked and to add five seconds for every later request once told to slow down. What this package owns is what no library can — refusing a deployment that does not advertise the grant *before* a code is printed, so nobody carries a code to an approval screen that does not exist. Three decisions worth stating. No new problem codes. The four endings leave the caller in one place, holding no session, so they share auth.credential_unavailable and differ in their sentence — the way identityNotVerified already varies four messages across one code. authorization_pending and slow_down are loop control and reach no user at all. This leaves the question in #40 open rather than answering it in passing. No device source. Deriving module access from a device-established session is identical to deriving it from a browser one at every step, so browserSource becomes sessionSource — named for what it derives from rather than for how the session was established — and both interactive kinds resolve to it. A parallel source would have duplicated the rotation lock and the narrowing proof for no behaviour. A missing identity token does not fail a device login, unlike a browser one. RFC 8628 defines no nonce, whether WSO2 deployments return an identity token from this grant is unmeasured, and the session is the refresh token — so the subject is reported when it was verified and is absent when it was not, rather than letting an unmeasured behaviour decide whether the flow works at all. Refs #42 --- internal/app/login.go | 85 ++++-- internal/app/login_test.go | 5 +- internal/auth/auth_test.go | 9 +- internal/auth/oauthflow/device.go | 245 ++++++++++++++++++ internal/auth/oauthflow/login.go | 27 +- internal/auth/source.go | 15 +- .../{source_browser.go => source_session.go} | 19 +- ...browser_test.go => source_session_test.go} | 0 8 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 internal/auth/oauthflow/device.go rename internal/auth/{source_browser.go => source_session.go} (89%) rename internal/auth/{source_browser_test.go => source_session_test.go} (100%) diff --git a/internal/app/login.go b/internal/app/login.go index 49c2441..ffd549c 100644 --- a/internal/app/login.go +++ b/internal/app/login.go @@ -42,6 +42,13 @@ const NonInteractiveEnvVar = "WSO2_NON_INTERACTIVE" // because without it an abandoned login waits forever holding a callback port. var loginDeadline = 5 * time.Minute +// deviceLoginDeadline is the same bound for a device login, and is longer for a +// plain reason: the user has to reach a second device before they can even +// begin. It is a ceiling and rarely the thing that fires — the deployment +// publishes its own device code lifetime, which the flow honours and which is +// usually shorter. +var deviceLoginDeadline = 15 * time.Minute + // loginFlags are the flags wso2 login owns. It owns all of them: unlike a // product command, there is no module to pass an unrecognized argument on to. type loginFlags struct { @@ -80,14 +87,14 @@ func (s Shell) login(args []string) error { fmt.Sprintf("the %q context acquires access inline and has no login step", selected.Context.Name)). WithRecovery("Run the product command directly; the shell authenticates during it.") - case contexts.KindOAuthDevice, contexts.KindPAT: + case contexts.KindPAT: return problem.New(problem.CategoryAuthPolicy, "auth.kind_not_implemented", fmt.Sprintf("the %q context uses an authentication kind this release does not implement", selected.Context.Name)). - WithRecovery("Use a browser or client-credentials identity. Device and personal access " + - "token login are planned.") - case contexts.KindOAuthBrowser: - // The one kind this release logs in interactively. + WithRecovery("Use a browser, device-code, or client-credentials identity. Personal " + + "access token login is planned.") + case contexts.KindOAuthBrowser, contexts.KindOAuthDevice: + // The two kinds this release logs in interactively. default: return problem.New(problem.CategoryAuthPolicy, "auth.method_unsupported", fmt.Sprintf("the %q context uses an authentication method this shell does not implement", @@ -95,8 +102,15 @@ func (s Shell) login(args []string) error { WithRecovery("Select a context with a supported authentication kind.") } if flags.nonInteractive || os.Getenv(NonInteractiveEnvVar) != "" { + // Named for the mode actually refused. Both are interactive and both + // are wrong in CI, but telling a device login it is a browser login + // sends the reader looking for a browser that was never involved. + mode := "browser login" + if selected.Identity.Auth.Kind == contexts.KindOAuthDevice { + mode = "device login" + } return problem.New(problem.CategoryAuthPolicy, "auth.non_interactive", - "browser login cannot run in non-interactive mode"). + mode+" cannot run in non-interactive mode"). WithRecovery("Use a client-credentials identity for automation; it acquires access " + "inline without a login step.") } @@ -105,19 +119,7 @@ func (s Shell) login(args []string) error { if err != nil { return err } - ctx, cancel := context.WithTimeout(context.Background(), loginDeadline) - defer cancel() - result, err := oauthflow.Login{ - Issuer: selected.Identity.Auth.Issuer, - ClientID: selected.Identity.Auth.ClientID, - Scopes: productScopeUnion(selected.Identity), - OpenBrowser: s.OpenBrowser, - // The authorization URL is an instruction to act on, not this - // command's result, so it goes to the diagnostic stream: a user who - // redirects standard output still sees the URL the login cannot - // finish without, and the result stream carries only the report. - Out: s.Streams.Err, - }.Run(ctx) + result, err := s.establishSession(selected) if err != nil { return err } @@ -147,6 +149,41 @@ func (s Shell) login(args []string) error { return s.reportLogin(selected, result) } +// establishSession runs the login mode the selected identity's kind names. +// +// The two modes differ in how a person proves who they are and in nothing else: +// each returns the same result, and each is given the diagnostic stream to +// print on. What they print is an instruction to act on, not this command's +// result, so a user who redirects standard output still sees the URL or the +// code the login cannot finish without, and the result stream carries only the +// report. +func (s Shell) establishSession(selected contexts.Selection) (oauthflow.Result, error) { + if selected.Identity.Auth.Kind == contexts.KindOAuthDevice { + // A longer deadline than the browser login's, because a longer errand: + // the person has to reach another device, open a browser on it, and + // type a code, where a browser login's user is already looking at the + // page. The deployment's own code lifetime bounds this further, and + // almost always to something shorter. + ctx, cancel := context.WithTimeout(context.Background(), deviceLoginDeadline) + defer cancel() + return oauthflow.DeviceLogin{ + Issuer: selected.Identity.Auth.Issuer, + ClientID: selected.Identity.Auth.ClientID, + Scopes: productScopeUnion(selected.Identity), + Out: s.Streams.Err, + }.Run(ctx) + } + ctx, cancel := context.WithTimeout(context.Background(), loginDeadline) + defer cancel() + return oauthflow.Login{ + Issuer: selected.Identity.Auth.Issuer, + ClientID: selected.Identity.Auth.ClientID, + Scopes: productScopeUnion(selected.Identity), + OpenBrowser: s.OpenBrowser, + Out: s.Streams.Err, + }.Run(ctx) +} + // reportLogin states who the login proved you are and what that identity // reaches. // @@ -158,7 +195,15 @@ func (s Shell) reportLogin(selected contexts.Selection, result oauthflow.Result) selected.Context.Name); err != nil { return err } - fields := [][2]string{{"Subject", result.Subject}} + var fields [][2]string + // Both are reported only when the login actually verified them. A browser + // login always has a subject, because it refuses without a verified + // identity token; a device login may not, because RFC 8628's grant is not + // defined to carry one and the session does not depend on it. An empty + // label would claim the shell knows something it does not. + if result.Subject != "" { + fields = append(fields, [2]string{"Subject", result.Subject}) + } if result.Email != "" { fields = append(fields, [2]string{"Email", result.Email}) } diff --git a/internal/app/login_test.go b/internal/app/login_test.go index 939deb8..deadef4 100644 --- a/internal/app/login_test.go +++ b/internal/app/login_test.go @@ -114,7 +114,10 @@ func TestLoginRefusals(t *testing.T) { {"client credentials", identityDoc(contexts.KindClientCredentials), nil, nil, "auth.login_not_required"}, {"non-interactive with an inline identity", identityDoc(contexts.KindClientCredentials), []string{"--non-interactive"}, nil, "auth.login_not_required"}, - {"device kind", identityDoc(contexts.KindOAuthDevice), nil, nil, "auth.kind_not_implemented"}, + // A device login is interactive too, so it is refused in CI for the + // same reason a browser login is: nothing may wait on a human there. + {"non-interactive with a device identity", identityDoc(contexts.KindOAuthDevice), + []string{"--non-interactive"}, nil, "auth.non_interactive"}, {"personal access token kind", identityDoc(contexts.KindPAT), nil, nil, "auth.kind_not_implemented"}, } for _, testCase := range cases { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c28926d..6b3b992 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -320,9 +320,12 @@ func TestTheIdentityKindDecidesWhichPolicyTheBrokerApplies(t *testing.T) { kind: "", code: "auth.context_not_selected", }, - "a device login is legal but unimplemented": { - kind: contexts.KindOAuthDevice, - code: "auth.kind_not_implemented", + // A device identity reaches the same session source a browser one does, + // so it is refused for the same product reasons and never for its kind. + "a device identity that configures no product": { + kind: contexts.KindOAuthDevice, + mutate: func(b *auth.Broker) { b.Selection.Identity.Products = nil }, + code: "auth.product_not_configured", }, "a personal access token is legal but unimplemented": { kind: contexts.KindPAT, diff --git a/internal/auth/oauthflow/device.go b/internal/auth/oauthflow/device.go new file mode 100644 index 0000000..b1137b7 --- /dev/null +++ b/internal/auth/oauthflow/device.go @@ -0,0 +1,245 @@ +// 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 ( + "context" + "errors" + "fmt" + "io" + "net/http" + + oidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// DeviceLogin runs one RFC 8628 Device Authorization Grant login. +// +// It exists for the machine the browser login cannot serve. That login binds a +// loopback listener and waits to be redirected back to it, so finishing it +// needs a browser that can reach 127.0.0.1 on this machine — which a developer +// working over SSH, or inside a container, does not have. This flow binds +// nothing and waits for nothing local: the shell prints a short URI and a code, +// and the approval happens on whatever device the person actually has a browser +// on. +// +// What it produces is what the browser login produces, so everything downstream +// of a session is identical and no product module can tell the two apart. +type DeviceLogin struct { + // Issuer is the OpenID provider to discover and authenticate against. + Issuer string + // ClientID is the public OAuth client this shell presents itself as. As in + // the browser login there is no client secret; here the device code is what + // binds the eventual token to the request this process made. + ClientID string + // Scopes are the permissions to request beyond openid and offline_access — + // the identity's product scope union. + Scopes []string + // HTTPClient serves discovery, the device authorization request, and the + // polling. It defaults to http.DefaultClient. + HTTPClient *http.Client + // Out receives the verification instructions. It defaults to standard + // output, because a device login whose code goes nowhere is a login nobody + // can complete. + Out io.Writer +} + +// Run performs the login and returns the issued token with the identity behind +// it. +// +// The protocol is x/oauth2's, deliberately: it owns the polling rule this flow +// most needs to get right — poll no faster than the interval the deployment +// advertised, add five seconds for this and every later request when told to +// slow down, and stop when the code expires. Reimplementing that would put the +// shell in the position of load-testing deployments it does not own. +// +// What this file owns is the part no library can: refusing a deployment that +// does not offer the grant before anything is printed, presenting two values so +// they survive being carried to another device, and turning every failure into +// a typed problem that never repeats the deployment's own words. +func (d DeviceLogin) Run(ctx context.Context) (Result, error) { + ctx = oidc.ClientContext(ctx, d.httpClient()) + provider, err := oidc.NewProvider(ctx, d.Issuer) + if err != nil { + return Result{}, discoveryFailed( + "the shell could not read the identity provider's OpenID configuration", + "Check the issuer of the selected context and that this machine can reach it, then retry.") + } + + endpoint := provider.Endpoint() + // The advertised endpoint is the capability test, and it is made before a + // code is printed rather than after. A deployment that does not offer the + // grant would otherwise leave a user reading out a code towards an approval + // screen that does not exist, and blaming themselves for it. + if endpoint.DeviceAuthURL == "" { + return Result{}, discoveryFailed( + "the identity provider does not advertise the device authorization grant", + "Enable the device authorization grant on the registered OAuth application, or select a "+ + "context whose identity logs in through the browser. Not every deployment offers this "+ + "grant.") + } + // A public client names itself in the request body, as RFC 6749 requires of + // one. Saying so explicitly also spares every request the library's probe + // with HTTP Basic credentials this shell does not have. + endpoint.AuthStyle = oauth2.AuthStyleInParams + config := oauth2.Config{ClientID: d.ClientID, Endpoint: endpoint, Scopes: d.scopes()} + + authorization, err := config.DeviceAuth(ctx) + if err != nil { + return Result{}, notCompleted( + "the identity provider would not start a device authorization for this login", + "Confirm the client identifier in the selected context, and that its OAuth application is "+ + "registered for the device authorization grant, then retry wso2 login.") + } + if err := d.present(authorization); err != nil { + return Result{}, err + } + + token, err := config.DeviceAccessToken(ctx, authorization) + if err != nil { + return Result{}, approvalFailed(err) + } + return d.identify(ctx, provider, token), nil +} + +// present writes the two values the user has to carry to another device. +// +// Both are printed on their own indented line rather than inside a sentence, +// because a terminal that wraps must not be able to split either one. The user +// code is written exactly as the deployment issued it: RFC 8628 section 6.1 +// already asks a deployment to make the value readable, and the deployment is +// the party that validates what gets typed back, so a shell that re-cased it or +// stripped its separator would be prettifying a value it does not own. +// +// The device code is never printed. It is the value exchangeable for a session, +// and only the user code is meant for human eyes. +func (d DeviceLogin) present(authorization *oauth2.DeviceAuthResponse) error { + _, err := fmt.Fprintf(d.out(), + "To log in, visit:\n\n %s\n\nand enter the code:\n\n %s\n\n", + authorization.VerificationURI, authorization.UserCode) + if err != nil { + return notCompleted("the shell could not print the instructions this login needs", + "Run wso2 login with its diagnostic output attached to your terminal.") + } + // The complete URI carries the code already, so it saves typing on a device + // that can follow a link. It is offered second and never alone: it cannot be + // read aloud, and RFC 8628 makes it optional, so a login must not depend on + // a deployment publishing one. + if authorization.VerificationURIComplete != "" { + _, _ = fmt.Fprintf(d.out(), "Or open this link, which carries the code:\n\n %s\n\n", + authorization.VerificationURIComplete) + } + _, _ = fmt.Fprint(d.out(), "Waiting for you to approve this login...\n") + return nil +} + +// identify reads who the login proved you are. +// +// Unlike the browser login, a device login is not refused for want of an +// identity token. The browser login can afford to refuse because the +// authorization code flow is defined to carry one and there is a nonce to check +// it against; RFC 8628 defines no nonce, and whether WSO2 deployments return an +// identity token from this grant is not measured. The session is the refresh +// token, so a login that produced one has produced everything the shell needs, +// and refusing over a claim nothing depends on would let an unmeasured +// behaviour decide whether this flow works at all. +// +// What binds the answer to this process is the device code: it was minted for +// this request and is spent by it. So the token is verified when it is there — +// the issuer's signature, and the audience naming this client — and the subject +// is simply absent when it is not. +func (d DeviceLogin) identify( + ctx context.Context, provider *oidc.Provider, token *oauth2.Token, +) Result { + result := Result{Token: token} + raw, _ := token.Extra("id_token").(string) + if raw == "" { + return result + } + verified, err := provider.Verifier(&oidc.Config{ClientID: d.ClientID}).Verify(ctx, raw) + if err != nil { + // An identity token that does not verify is reported as no identity at + // all rather than as a failed login, for the same reason the absent one + // is: nothing the session does depends on it. Claiming a subject the + // issuer's keys did not vouch for is the one thing that would be worse. + return result + } + var claims struct { + Email string `json:"email"` + } + _ = verified.Claims(&claims) + result.Subject = verified.Subject + result.Email = claims.Email + return result +} + +// approvalFailed reports a device authorization that ended without a token, and +// says which of the endings it was. +// +// The code is the same for all of them because the caller is left in one place, +// holding no session. The message is not, because the reader is not: a person +// who declined the request themselves, a person who was too slow, and a person +// whose deployment broke have three different things to do, and only one of them +// is helped by simply running the command again. +// +// RFC 8628's two waiting answers — authorization_pending and slow_down — never +// arrive here. They are how the deployment says "keep going", the library acts +// on both, and neither is a failure to report. +func approvalFailed(err error) error { + var refusal *oauth2.RetrieveError + if errors.As(err, &refusal) { + switch refusal.ErrorCode { + case "access_denied": + return notCompleted("the login was declined at the identity provider", + "Run wso2 login again and approve the request, checking that the code shown in the "+ + "terminal is the one on the approval screen.") + case "expired_token": + return notCompleted("the approval window closed before this login was approved", + "Run wso2 login again and approve the request promptly. The code is short-lived by "+ + "design.") + } + return notCompleted("the identity provider refused to complete this device login", + "Retry wso2 login. If it keeps failing, confirm the client identifier and that the OAuth "+ + "application is registered for the device authorization grant.") + } + // The library turns an elapsed deadline into the context's own error. The + // user is where an expired code leaves them, and is told the same thing. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return notCompleted("this login was not approved in time", + "Run wso2 login again and approve the request promptly. The code is short-lived by design.") + } + return notCompleted("the shell lost contact with the identity provider while waiting for approval", + "Check that this machine can reach the issuer of the selected context, then retry wso2 login.") +} + +// scopes asks for the same permissions a browser login asks for, so a session +// established either way narrows down to the same product access afterwards. +func (d DeviceLogin) scopes() []string { + return Login{Scopes: d.Scopes}.scopes() +} + +// httpClient wraps the caller's client so a key set is read for its keys and +// not for the certificates beside them. See certificateStripper — WSO2 +// deployments publish certificates Go will not parse, and this flow verifies +// identity tokens against those key sets exactly as the browser login does. +func (d DeviceLogin) httpClient() *http.Client { + return Login{HTTPClient: d.HTTPClient}.httpClient() +} + +func (d DeviceLogin) out() io.Writer { + return Login{Out: d.Out}.out() +} diff --git a/internal/auth/oauthflow/login.go b/internal/auth/oauthflow/login.go index f92098a..9e3d8c6 100644 --- a/internal/auth/oauthflow/login.go +++ b/internal/auth/oauthflow/login.go @@ -14,17 +14,28 @@ // specific language governing permissions and limitations // under the License. -// Package oauthflow runs one browser Authorization Code + PKCE login. +// Package oauthflow runs one interactive login, in either of the two modes an +// OIDC identity can be established by. +// +// Login is the browser Authorization Code + PKCE mode, the default for a person +// sitting at the machine. DeviceLogin is the RFC 8628 Device Authorization +// Grant mode, for a machine whose user has no browser it can reach — a remote +// shell, a container — where the approval happens on another device entirely. +// Both produce the same Result, so a session established either way is +// indistinguishable everywhere downstream. // // The standard libraries own the protocol: go-oidc discovers the issuer and -// verifies the identity token, and x/oauth2 builds the authorization URL and -// exchanges the code. This package owns only what they cannot — binding a -// callback port the OAuth application is actually registered for, printing the -// authorization URL before anything else can fail, and proving the callback -// that arrives belongs to the login this process started. +// verifies the identity token, and x/oauth2 builds the authorization URL, +// exchanges the code, and runs the device polling loop. This package owns only +// what they cannot — binding a callback port the OAuth application is actually +// registered for, printing what the user must act on before anything else can +// fail, proving the callback that arrives belongs to the login this process +// started, and turning every failure into a typed problem that never repeats +// the deployment's own words. // -// No value the flow produces is ever written to the flow's output stream: the -// terminal sees a URL and progress, never token material. +// No value the flows produce is ever written to their output stream: the +// terminal sees a URL, a user code, and progress — never token material, and +// never the device code. package oauthflow import ( diff --git a/internal/auth/source.go b/internal/auth/source.go index 8f94187..db632ab 100644 --- a/internal/auth/source.go +++ b/internal/auth/source.go @@ -55,7 +55,7 @@ func (b *Broker) resolveSource(request Request) (source, error) { "Select a context that names the organization and credential source to use.") case contexts.MethodDevelopmentCredential: return b.developmentSource() - case contexts.KindOAuthBrowser, contexts.KindClientCredentials: + case contexts.KindOAuthBrowser, contexts.KindOAuthDevice, contexts.KindClientCredentials: if err := b.checkProduct(request); err != nil { return nil, err } @@ -65,18 +65,23 @@ func (b *Broker) resolveSource(request Request) (source, error) { if kind == contexts.KindClientCredentials { return b.inlineSource() } - return browserSource{ + // Both interactive kinds land here, and deliberately on the same + // source. How a session was established is a fact about a login that + // already happened; what is left behind is a refresh token, and every + // step from here — the rotation lock, the scoped refresh, the proof + // that the narrowing held — reads that and nothing else. + return sessionSource{ namespace: b.namespace(), identity: b.Selection.Identity, sessions: session.Store{StateRoot: b.StateRoot}, client: b.httpClient(), }, nil - case contexts.KindOAuthDevice, contexts.KindPAT: + case contexts.KindPAT: return nil, denial("auth.kind_not_implemented", fmt.Sprintf("the %q context uses an authentication kind this release does not implement", b.Selection.Context.Name), - "Select a context whose identity logs in through the browser, or one that uses "+ - "client credentials. Device and personal access token login are planned.") + "Select a context whose identity logs in through the browser or through a device code, "+ + "or one that uses client credentials. Personal access token login is planned.") default: return nil, denial("auth.method_unsupported", fmt.Sprintf("the %q context uses an authentication method this shell does not implement", diff --git a/internal/auth/source_browser.go b/internal/auth/source_session.go similarity index 89% rename from internal/auth/source_browser.go rename to internal/auth/source_session.go index 1c40bd1..67e51fb 100644 --- a/internal/auth/source_browser.go +++ b/internal/auth/source_session.go @@ -29,7 +29,7 @@ import ( "github.com/wso2/wso2-cli/internal/contexts" ) -// browserSource derives one module's access from the stored login session. +// sessionSource derives one module's access from the stored login session. // // The strategy is a scoped refresh: the session was granted the union of the // identity's product permissions at login, and each module's request is a @@ -38,7 +38,14 @@ import ( // exactly the permissions asked for and is bound to the audience asked for, and // refuses when it cannot. A deployment that ignores the narrowing would // otherwise hand every module the whole session's authority. -type browserSource struct { +// +// It is named for what it derives from and not for how the session was +// established, because that is the whole of the difference between the +// interactive kinds: a browser login and a device login both end at a refresh +// token in the secure store, and from there every step below is the same one. +// A second source per login mode would duplicate the rotation lock and the +// narrowing proof for no behaviour. +type sessionSource struct { // namespace is the module asking, named in refusals. namespace string // identity is the logged-in identity: issuer, client, and the secure-store @@ -56,7 +63,7 @@ type browserSource struct { // token, because a rotating issuer invalidates what it was presented: two // invocations refreshing the same session concurrently would leave one of them // holding a token the issuer has already replaced. -func (s browserSource) mint(request Request, now time.Time) (Grant, error) { +func (s sessionSource) mint(request Request, now time.Time) (Grant, error) { var granted Grant err := s.sessions.WithLock(s.identity.Auth.CredentialRef, func() error { issued, err := s.derive(request, now) @@ -73,7 +80,7 @@ func (s browserSource) mint(request Request, now time.Time) (Grant, error) { } // derive runs one scoped refresh under the lock mint holds. -func (s browserSource) derive(request Request, now time.Time) (Grant, error) { +func (s sessionSource) derive(request Request, now time.Time) (Grant, error) { stored, err := s.sessions.Load(s.identity.Auth.CredentialRef) if err != nil { return Grant{}, err @@ -123,7 +130,7 @@ func (s browserSource) derive(request Request, now time.Time) (Grant, error) { } // refresh exchanges the stored refresh token for access narrowed to scopes. -func (s browserSource) refresh( +func (s sessionSource) refresh( ctx context.Context, endpoint, refreshToken string, scopes []string, ) (tokenResponse, error) { issued, err := requestToken(ctx, s.client, endpoint, url.Values{ @@ -143,7 +150,7 @@ func (s browserSource) refresh( // A refusal to narrow is the one answer treated differently: it means the // session is fine and the deployment will not scope it down, which is a // registration problem, not a login problem. -func (s browserSource) refusedGrant(err error) error { +func (s sessionSource) refusedGrant(err error) error { var refusal issuerRefusal switch { case errors.As(err, &refusal) && refusal.refusedToNarrow(): diff --git a/internal/auth/source_browser_test.go b/internal/auth/source_session_test.go similarity index 100% rename from internal/auth/source_browser_test.go rename to internal/auth/source_session_test.go From 0cb765dc678165990fe7839261abd8771d5b82d0 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 19:54:21 +0530 Subject: [PATCH 3/7] test(acceptance): prove the device login through the shell command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One seam, the same one the browser login is proven at: `wso2 login` run in process against the deterministic issuer, with the reference module launched as a real subprocess and presenting its token to a real service. Nothing reaches into the flow — what is asserted is the two streams, the exit class, what landed in the secure store, and what the product service was shown. The interval and back-off are read from the issuer's record of when each poll arrived, because "the deployment was not polled faster than it asked" is a fact about the deployment. That test spends real seconds and cannot avoid it: RFC 8628 fixes the back-off at five, and the shell honours the RFC rather than a knob this suite could turn down. Covered: an approved login establishes a session and a module then receives access narrowed to one permission out of four; the code and URI each stand on their own line so they survive being read aloud; neither the device code nor any token material reaches any stream; a declined login and an abandoned one read differently under one code; a deployment without the grant is refused before any code is shown and without a single poll; and a login whose grant returned no identity token still establishes a usable session while claiming no subject. Refs #42 --- test/acceptance/login_device_test.go | 376 +++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 test/acceptance/login_device_test.go diff --git a/test/acceptance/login_device_test.go b/test/acceptance/login_device_test.go new file mode 100644 index 0000000..f281ffe --- /dev/null +++ b/test/acceptance/login_device_test.go @@ -0,0 +1,376 @@ +// 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 acceptance_test + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/wso2/wso2-cli/internal/auth/fakeissuer" + "github.com/wso2/wso2-cli/internal/auth/session" + "github.com/wso2/wso2-cli/internal/contexts" + "github.com/wso2/wso2-cli/internal/exit" +) + +// This file proves the device authorization login — the mode for a machine +// whose user has no browser that can reach it. +// +// Everything is asserted through `wso2 login` and, where the subject is what a +// module ends up holding, through a product command after it. Nothing reaches +// into the flow: what a user gets is the two streams, the exit class, what +// landed in the secure store, and what the product service was shown. +// +// The deployment's side of the polling contract is read from the fake issuer, +// which timestamps every poll it answers. That is deliberately the only place +// timing is observed — "the shell did not poll faster than I asked it to" is a +// fact about the deployment, and asserting it from there cannot be satisfied by +// a shell that merely holds the right number in a variable. + +// deviceDeployment installs the standard login deployment as a device identity. +// +// Only the kind changes. That is the point of the fixture: a device context and +// a browser context differ by one word, because the schema already treats them +// as one shape and everything after the login is the same code. +func deviceDeployment(t *testing.T, options fakeissuer.Options) *loginDeployment { + t.Helper() + return deployLogin(t, options, func(document *contexts.Document) { + document.Identities[0].Auth.Kind = contexts.KindOAuthDevice + }) +} + +// pollableDevice is the option set every test that expects to reach the token +// endpoint starts from. +// +// The advertised interval is one second because the shell honours what the +// deployment advertises, and the RFC's own default of five would spend four of +// them proving nothing. A test that is about the default says so by leaving +// DeviceInterval unset. +func pollableDevice(outcome string, pending int) fakeissuer.Options { + return fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceOutcome: outcome, + DevicePendingPolls: pending, + DeviceInterval: 1, + RotateRefreshTokens: true, + } +} + +// userCodePattern is the shape RFC 8628 section 6.1 recommends and the fake +// issuer mints: two groups of readable upper-case letters around a separator. +var userCodePattern = regexp.MustCompile(`\b[A-Z]{4}-[A-Z]{4}\b`) + +func TestADeviceLoginEstablishesASessionAndTheModuleReceivesNarrowedAccess(t *testing.T) { + // The whole chain for the device mode, and the claim that matters most: + // what a module ends up holding is identical to what a browser login would + // have produced. The session carries four permissions and the module asked + // for one, so the token it presents proves the narrowing happened on a + // session nobody opened a browser for. + deployment := deviceDeployment(t, pollableDevice("approve", 1)) + + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + afterLogin := deployment.storedSession(t) + if afterLogin.RefreshToken == "" { + t.Fatal("the device login stored no refresh token, so there is no session") + } + if afterLogin.Issuer != deployment.issuer.URL { + t.Errorf("the stored session names issuer %q, want %q", + afterLogin.Issuer, deployment.issuer.URL) + } + + if code := deployment.status(t); code != exit.OK { + t.Fatalf("reference status exited %d\nstderr:\n%s", code, deployment.errOut) + } + presented := deployment.service.presented() + if len(presented) != 1 { + t.Fatalf("the product service was shown %d bearer tokens, want 1", len(presented)) + } + active, scopes, audiences := deployment.issuer.Introspect(t, presented[0]) + if !active { + t.Fatal("the module presented a token the issuer did not mint") + } + if len(scopes) != 1 || scopes[0] != referenceReadScope { + t.Errorf("the module presented scopes %v, want exactly [%s]", scopes, referenceReadScope) + } + if len(audiences) != 1 || audiences[0] != referenceAudience { + t.Errorf("the module presented audience %v, want [%s]", audiences, referenceAudience) + } + // The rotation-safe persistence is shared with the browser path, and this + // proves the device path reaches it rather than bypassing it. + if deployment.storedSession(t).RefreshToken == afterLogin.RefreshToken { + t.Error("the derivation did not persist the rotated refresh token") + } +} + +func TestADeviceLoginPrintsTheCodeAndURIWhereTheUserCanActOnThem(t *testing.T) { + // The two values have to survive being carried to another device — read + // aloud, or typed by hand. So each is asserted to stand alone on its own + // line rather than merely to appear somewhere in a paragraph. + deployment := deviceDeployment(t, pollableDevice("approve", 0)) + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + + instructions := deployment.errOut.String() + // The instructions go to the diagnostic stream, so a user who redirects + // standard output can still complete the login. The result stream carries + // the report and nothing a user must act on. + if strings.Contains(deployment.out.String(), "enter the code") { + t.Errorf("the login instructions reached standard output:\n%s", deployment.out) + } + + code := userCodePattern.FindString(instructions) + if code == "" { + t.Fatalf("no user code was printed:\n%s", instructions) + } + if !standsAlone(instructions, code) { + t.Errorf("the user code %q is not on a line of its own, so it cannot be read out:\n%s", + code, instructions) + } + if !standsAlone(instructions, deployment.issuer.URL+"/device") { + t.Errorf("the verification URI is not on a line of its own:\n%s", instructions) + } + if !strings.Contains(instructions, "Waiting for you") { + t.Errorf("the login never said it was waiting, so it reads as hung:\n%s", instructions) + } + // The complete URI is a convenience and is offered second. It carries the + // code, so it cannot replace the two values above. + if !strings.Contains(instructions, deployment.issuer.URL+"/device?user_code="+code) { + t.Errorf("the advertised complete verification URI was not offered:\n%s", instructions) + } +} + +// standsAlone reports whether value occupies a line by itself, ignoring the +// indentation the instructions use. A value wrapped into a sentence is one a +// terminal may break in the middle of. +func standsAlone(text, value string) bool { + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == value { + return true + } + } + return false +} + +func TestNoDeviceCodeOrTokenMaterialReachesAnyOutputSurfaceOfADeviceLogin(t *testing.T) { + // A device login prints more than a browser login does, so the + // non-disclosure sweep is repeated for it. The device code is the addition + // worth naming: it is exchangeable for the session, unlike the user code + // beside it, and printing the two together would be the easy mistake. + deployment := deviceDeployment(t, pollableDevice("approve", 0)) + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + loginOut, loginErr := deployment.out.String(), deployment.errOut.String() + afterLogin := deployment.storedSession(t) + + if code := deployment.status(t); code != exit.OK { + t.Fatalf("reference status exited %d\nstderr:\n%s", code, deployment.errOut) + } + presented := deployment.service.presented() + if len(presented) != 1 { + t.Fatalf("the product service was shown %d bearer tokens, want 1", len(presented)) + } + + material := map[string]string{ + "the login's refresh token": afterLogin.RefreshToken, + "the login's access token": afterLogin.AccessToken, + "the module's access token": presented[0], + "the device code": deployment.issuer.LastDeviceCode(), + } + surfaces := map[string]string{ + "login standard output": loginOut, + "login diagnostics": loginErr, + "status standard output": deployment.out.String(), + "status diagnostics": deployment.errOut.String(), + } + for label, secret := range material { + if secret == "" { + t.Fatalf("%s is empty, so this sweep scans for nothing", label) + } + for surface, stream := range surfaces { + if strings.Contains(stream, secret) { + t.Errorf("%s appeared on %s:\n%s", label, surface, stream) + } + } + } +} + +func TestADeclinedDeviceLoginAndAnAbandonedOneReadDifferently(t *testing.T) { + // Both leave the user holding no session, so both carry the same code — + // the shell's codes name where a caller is left, not what the deployment + // said. What differs is the sentence, because the two readers have + // different things to do: one of them chose this, and the other ran out of + // time. + for name, testcase := range map[string]struct { + options fakeissuer.Options + expect string + reject string + }{ + "declined at the identity provider": { + options: pollableDevice("deny", 0), + expect: "declined", + reject: "closed before", + }, + "the approval window closed": { + options: pollableDevice("expire", 0), + expect: "closed before", + reject: "declined", + }, + "never approved before the code expired": { + // The deployment keeps answering "pending" and the code's own + // lifetime runs out. The shell stops because the deployment said + // when it would stop being worth asking, which is a different path + // through the flow than an expired_token answer. + options: fakeissuer.Options{ + RefreshScopeMode: "honor", + DevicePendingPolls: 1000, + DeviceInterval: 3, + DeviceExpiresIn: 1, + }, + expect: "in time", + reject: "declined", + }, + } { + t.Run(name, func(t *testing.T) { + deployment := deviceDeployment(t, testcase.options) + + if code := deployment.shell.Run([]string{"login"}); code != exitAuthPolicy { + t.Fatalf("wso2 login exited %d, want the authentication class %d\nstderr:\n%s", + code, exitAuthPolicy, deployment.errOut) + } + refusal := deployment.errOut.String() + if !strings.Contains(refusal, "auth.credential_unavailable") { + t.Errorf("the refusal did not carry auth.credential_unavailable:\n%s", refusal) + } + if !strings.Contains(refusal, testcase.expect) { + t.Errorf("the refusal does not say %q:\n%s", testcase.expect, refusal) + } + if strings.Contains(refusal, testcase.reject) { + t.Errorf("the refusal reads as the wrong ending, saying %q:\n%s", + testcase.reject, refusal) + } + if _, err := (session.Store{StateRoot: deployment.stateRoot}). + Load(loginCredentialRef); err == nil { + t.Error("a device login that never completed stored a session anyway") + } + }) + } +} + +func TestADeploymentWithoutTheDeviceGrantIsRefusedBeforeAnyCodeIsShown(t *testing.T) { + // Thunder is such a deployment today. The refusal has to come before the + // instructions, because a user who has already been given a code will go + // and look for an approval screen that does not exist, and will blame + // themselves when they cannot find it. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + OmitDeviceEndpoint: true, + }) + + if code := deployment.shell.Run([]string{"login"}); code != exitAuthPolicy { + t.Fatalf("wso2 login exited %d, want the authentication class %d\nstderr:\n%s", + code, exitAuthPolicy, deployment.errOut) + } + refusal := deployment.errOut.String() + if !strings.Contains(refusal, "auth.discovery_failed") { + t.Errorf("the refusal did not carry auth.discovery_failed:\n%s", refusal) + } + if !strings.Contains(refusal, "does not advertise the device authorization grant") { + t.Errorf("the refusal does not name the missing grant:\n%s", refusal) + } + if userCodePattern.MatchString(refusal) || strings.Contains(refusal, "enter the code") { + t.Errorf("a code was shown for a login that could never be approved:\n%s", refusal) + } + if polls := deployment.issuer.DevicePolls(); len(polls) != 0 { + t.Errorf("the shell polled the token endpoint %d times without a device grant", len(polls)) + } +} + +func TestADeviceLoginHonoursTheAdvertisedIntervalAndBacksOffWhenTold(t *testing.T) { + // The deployment's protection against a fleet of shells. Asserted from the + // deployment's own record of when each poll arrived, because that is the + // only place the property is real. + // + // This test spends real seconds, and cannot avoid it: RFC 8628 fixes the + // back-off at five seconds and the shell honours the RFC rather than a knob + // this suite could turn down. It is the one test here that waits, and it + // waits once. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: 1, + DeviceSlowDownPolls: 1, + DeviceOutcome: "approve", + }) + + started := time.Now() + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + polls := deployment.issuer.DevicePolls() + if len(polls) != 2 { + t.Fatalf("the deployment was polled %d times, want 2 (one refused with slow_down, "+ + "one approved)", len(polls)) + } + // The flow waits one interval before asking at all, which is what keeps a + // deployment from being hit the instant it issues a code. + if first := polls[0].Sub(started); first < time.Second { + t.Errorf("the first poll arrived after %v, sooner than the advertised 1s interval", first) + } + // The back-off is the point: after slow_down the gap must exceed what was + // advertised, and by RFC 8628 section 3.5 it grows by five seconds. + gap := polls[1].Sub(polls[0]) + if gap < 5*time.Second { + t.Errorf("the poll after slow_down came %v later; the interval did not grow by the "+ + "five seconds RFC 8628 requires", gap) + } +} + +func TestADeviceLoginWithoutAnIdentityTokenStillEstablishesASession(t *testing.T) { + // Deliberately unlike the browser login, which refuses without a verified + // identity token. RFC 8628 defines no nonce and whether WSO2 deployments + // return an identity token from this grant is not measured, so the session + // — which is the refresh token — is not made to depend on one. What the + // shell must not do is claim a subject it never verified. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: 1, + OmitDeviceIDToken: true, + }) + + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + if deployment.storedSession(t).RefreshToken == "" { + t.Fatal("no session was stored, so an unmeasured issuer behaviour decided the login") + } + report := deployment.out.String() + if strings.Contains(report, "Subject") { + t.Errorf("the report claims a subject no identity token proved:\n%s", report) + } + if !strings.Contains(report, "Logged in") { + t.Errorf("the login did not report success:\n%s", report) + } + // The session still reaches a module, which is the whole reason the missing + // claim is tolerated rather than refused over. + if code := deployment.status(t); code != exit.OK { + t.Fatalf("reference status exited %d\nstderr:\n%s", code, deployment.errOut) + } +} From 1c331f6a3160a1905cfc8e1b207eed0c341bb513 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 19:54:34 +0530 Subject: [PATCH 4/7] test(smoke): prove the device grant against a live deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic suite proves the flow. What a live run adds is evidence about a deployment: that Asgardeo and Identity Server really advertise the endpoint the shell looks for, really answer the polling the way RFC 8628 describes, and really leave a refresh token the broker can narrow. It reads exactly the variables the browser run reads. The only thing it needs that the browser run does not is the device grant enabled on the same application — no registration value is specific to it — and the run exists partly to keep that claim honest. Config gains one field for the kind, defaulting to browser so nothing existing changes. It also reports whether the deployment's device grant returned an identity token. That is unmeasured on both products today, and it is the reason the shell refuses to depend on one. Refs #42 --- Makefile | 9 ++ test/smoke/config.go | 12 ++- test/smoke/env.example | 5 + test/smoke/login_device_smoke_test.go | 138 ++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 test/smoke/login_device_smoke_test.go diff --git a/Makefile b/Makefile index 2da0c21..a95ba7e 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,7 @@ help: @echo '' @echo 'Against a real deployment (Asgardeo or a local Identity Server 7.x):' @echo ' make smoke-login Log in and broker one acquisition. Opens a browser.' + @echo ' make smoke-login-device The same, approved on another device. Opens no browser.' @echo ' make empirical-asgardeo Run the two one-time experiments and print their verdicts.' @echo '' @echo 'Both live targets skip cleanly when no deployment is configured.' @@ -127,6 +128,14 @@ smoke-build: smoke-login: @$(smoke_env) $(GO) test $(SMOKE_FLAGS) $(SMOKE_PACKAGE) -run TestLoginSmoke +# The same deployment, logged in to without a browser. It reads exactly the +# variables smoke-login reads: nothing in the registration is specific to the +# device grant beyond enabling it on the application, and this target exists +# partly to keep that claim honest. Skips when no deployment is configured. +.PHONY: smoke-login-device +smoke-login-device: + @$(smoke_env) $(GO) test $(SMOKE_FLAGS) $(SMOKE_PACKAGE) -run TestDeviceLoginSmoke + # Answers the two questions the redirect-and-narrowing research left open, and # prints one verdict line each for recording in that document. Skips when no # deployment is configured. diff --git a/test/smoke/config.go b/test/smoke/config.go index 2545c46..a2b0155 100644 --- a/test/smoke/config.go +++ b/test/smoke/config.go @@ -132,6 +132,12 @@ type Config struct { UnregisteredPort int // Deadline bounds a run that is waiting on a human. Deadline time.Duration + // Kind is the authentication kind the installed document declares. It is + // empty for the browser login every existing run performs, and set to the + // device kind by the run that proves the device grant against the same + // deployment. No other variable changes between the two, because no other + // registration value differs. + Kind string } // Load reads one deployment's description through lookup. @@ -248,6 +254,10 @@ func RegisteredPorts() []int { return []int{10425, 10426, 10427, 10428} } // shape the shell reads, and it is exercised by this package's own tests so // that a run cannot fail on a document defect in front of a waiting human. func (c Config) Document() contexts.Document { + kind := c.Kind + if kind == "" { + kind = contexts.KindOAuthBrowser + } return contexts.Document{ SchemaVersion: contexts.SchemaVersion, DefaultContext: ContextName, @@ -255,7 +265,7 @@ func (c Config) Document() contexts.Document { Name: IdentityName, Type: c.IdentityType, Auth: contexts.IdentityAuth{ - Kind: contexts.KindOAuthBrowser, + Kind: kind, Issuer: c.Issuer, ClientID: c.ClientID, Tenant: c.Tenant, diff --git a/test/smoke/env.example b/test/smoke/env.example index c4cfbca..b525754 100644 --- a/test/smoke/env.example +++ b/test/smoke/env.example @@ -76,6 +76,11 @@ export WSO2_SMOKE_AUDIENCE="$WSO2_SMOKE_CLIENT_ID" # --- Optional ------------------------------------------------------------ +# Nothing below is needed for `make smoke-login-device`. It describes the same +# deployment from the same variables above and differs only in the kind the +# context document declares, which the run sets for itself. What it does need is +# the Device Code grant enabled on the application named by WSO2_SMOKE_CLIENT_ID. + # The identity's home organization. Left unset, the smoke context names none. # export WSO2_SMOKE_TENANT='' diff --git a/test/smoke/login_device_smoke_test.go b/test/smoke/login_device_smoke_test.go new file mode 100644 index 0000000..4a46919 --- /dev/null +++ b/test/smoke/login_device_smoke_test.go @@ -0,0 +1,138 @@ +// 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. + +//go:build smoke + +// This file runs only under `go test -tags smoke`. It waits for a real person +// to approve a real login on a second device and writes to the operating +// system's secure store, so it is kept out of the default gate by the tag +// rather than by a skip. + +package smoke_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/wso2/wso2-cli/internal/app" + "github.com/wso2/wso2-cli/internal/auth" + "github.com/wso2/wso2-cli/internal/auth/session" + "github.com/wso2/wso2-cli/internal/contexts" + fixture "github.com/wso2/wso2-cli/internal/contexts/fixture" + "github.com/wso2/wso2-cli/internal/exit" + "github.com/wso2/wso2-cli/internal/output" + "github.com/wso2/wso2-cli/test/smoke" +) + +// TestDeviceLoginSmoke drives the device authorization login against a +// deployment that really exists. +// +// It describes the same deployment as the browser run, from the same variables, +// and differs from it in one field of the installed document. That is the claim +// worth making live: no registration value is specific to the device grant, so +// a deployment already set up for `make smoke-login` needs only the grant +// enabled on the same application. +// +// The deterministic suite already proves the flow — the polling, the four +// endings, the narrowing afterwards — against a fake issuer. What this run adds +// is evidence about a *deployment*: that Asgardeo and Identity Server really +// advertise the endpoint the shell looks for, really answer the polling the way +// RFC 8628 describes, and really leave behind a refresh token the broker can +// narrow. +// +// One answer here is not yet known and is the reason to run this at all. +// Whether either product returns an identity token from the device grant is +// unmeasured, and the shell deliberately does not depend on one. This run +// reports which it saw, so the answer can be recorded rather than assumed. See +// issue #42. +func TestDeviceLoginSmoke(t *testing.T) { + config := requireDeployment(t) + config.Kind = contexts.KindOAuthDevice + + // A developer's own environment must not decide what this run proves. + t.Setenv("WSO2_CONTEXT", "") + t.Setenv("WSO2_NON_INTERACTIVE", "") + + stateRoot := filepath.Join(t.TempDir(), "state") + if err := fixture.WriteV2(stateRoot, config.Document()); err != nil { + t.Fatalf("cannot install the smoke context document: %v", err) + } + forgetSmokeSession(t) + + captured := &bytes.Buffer{} + shell := app.Shell{ + StateRoot: stateRoot, + // Both streams reach the terminal as well as the buffer. A human is + // about to be asked for a code they must type on another device, and + // they need it now rather than in the test's final report. + Streams: output.Streams{ + Out: io.MultiWriter(os.Stdout, captured), + Err: io.MultiWriter(os.Stderr, captured), + }, + } + + t.Logf("starting a device login against %s as client %s", config.Issuer, config.ClientID) + t.Log("approve it on any other device — a phone is fine; nothing has to reach this machine") + if code := shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\n%s", code, captured) + } + + stored, err := session.Store{StateRoot: stateRoot}.Load(smoke.CredentialRef) + if err != nil { + t.Fatalf("the device login did not leave a readable session: %v", err) + } + if stored.RefreshToken == "" { + t.Fatal("the stored session carries no refresh token, so no session was established") + } + if stored.Issuer != config.Issuer { + t.Fatalf("the stored session names issuer %q, want %q", stored.Issuer, config.Issuer) + } + t.Logf("session stored: refresh token of %d characters", len(stored.RefreshToken)) + + // The unmeasured behaviour, reported rather than asserted. The shell prints + // a subject only when it verified an identity token, so the report is the + // honest witness for whether this deployment's device grant returned one. + if bytes.Contains(captured.Bytes(), []byte("Subject")) { + t.Log("DEVICE LOGIN SMOKE: this deployment's device grant returned a verifiable identity token") + } else { + t.Log("DEVICE LOGIN SMOKE: this deployment's device grant returned no identity token — " + + "the session was established anyway, which is the designed behaviour") + } + + selection, err := config.Document().Select("") + if err != nil { + t.Fatalf("the smoke document selects no context: %v", err) + } + // The claim the whole slice rests on: after login there is no device path + // left. This is the same brokered acquisition the browser run makes, over a + // session no browser established, and it must narrow identically. + target, err := config.NarrowTarget() + if err != nil { + t.Logf("DEVICE LOGIN SMOKE: narrowing not measured — %v", err) + return + } + acquire(t, &auth.Broker{ + Namespace: smoke.Namespace, + Capabilities: config.Capabilities(), + Selection: selection, + InvocationID: "smoke-device-narrowed", + StateRoot: stateRoot, + }, auth.Request{Audience: config.Audience, Scopes: []string{target}}, + "narrowed", "one permission out of the several a device-established session holds") +} From 0f2fd54d791574404289ae2011ff8c956e87db42 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 19:54:35 +0530 Subject: [PATCH 5/7] docs: document logging in without a browser Section 5.1 of the walkthrough covers the device mode end to end: when the kind is the right choice, the one grant to enable, the one word that changes in the context document, and what the terminal actually prints. It is explicit about the gap this slice leaves. `oauth-device` is the kind for an identity that can *only* be established that way; a developer who merely happens to be on a headless machine today needs a second identity, because `wso2 login --device-code` is not in this release. Saying so is better than letting a reader discover it. Troubleshooting gains the four device endings and the missing-grant refusal, and its auth.kind_not_implemented entry narrows to `pat`, which is now the only kind that still refuses. The architecture and requirements notes are corrected to match what ships, and the examples table moves the kind to implemented. CONTEXT.md gains one term. "Login mode" was used across three documents and defined in none, and it is exactly the distinction this slice would otherwise blur with "authentication kind". Refs #42 --- CONTEXT.md | 7 ++ docs/architecture.md | 11 +- docs/examples/authentication-contexts.md | 14 ++- docs/guides/login.md | 129 ++++++++++++++++++++++- docs/product-requirements.md | 12 ++- 5 files changed, 158 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2beeece..ec76f4a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,3 +52,10 @@ _Avoid_: Integrity-checked module A non-production vertical slice that validates the riskiest architectural boundaries without claiming user-ready product value. _Avoid_: Pilot release, minimum viable product + +**Login mode**: +How one interactive identity's session is established on the machine at hand — +through a browser on this machine, or through a code approved on another +device. It is a property of the machine and the moment, not of the identity's +credentials, so the same identity may be established either way. +_Avoid_: Login type, authentication kind diff --git a/docs/architecture.md b/docs/architecture.md index c1fb98a..109182a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -214,12 +214,17 @@ that rather than presenting it as equivalent. #### Interactive login modes > **What ships today.** This section describes the target architecture. The -> first `wso2 login` slice implements browser Authorization Code with PKCE and -> inline client credentials. The Device Authorization Grant and personal access -> tokens validate as legal configuration and refuse at use with the stable code +> shell implements browser Authorization Code with PKCE, the Device +> Authorization Grant, and inline client credentials. Personal access tokens +> validate as legal configuration and refuse at use with the stable code > `auth.kind_not_implemented` — accepted so that a document written for them > stays readable, not executed. See > [the login first slice](plans/login-first-slice.md). +> +> The device grant is reached through the `oauth-device` **kind**, not yet +> through a login-time flag: `wso2 login --device-code` is not in this release. +> So the mode-not-kind rule below states the target, and today an identity that +> can only be established by device says so in its kind. Browser Authorization Code with PKCE and the Device Authorization Grant are two **login modes for the same interactive OIDC identity**, not two stored diff --git a/docs/examples/authentication-contexts.md b/docs/examples/authentication-contexts.md index 3bbf371..2b2a9d4 100644 --- a/docs/examples/authentication-contexts.md +++ b/docs/examples/authentication-contexts.md @@ -435,14 +435,14 @@ availability is per deployment, not universal: | Kind | Where it is valid | Today | | --- | --- | --- | | `oauth-browser` | supported by every identity backend | implemented | -| `oauth-device` | only where the backend advertises the grant; the broker refuses otherwise | validates, refuses at use with `auth.kind_not_implemented` | +| `oauth-device` | only where the backend advertises the grant; the broker refuses otherwise | implemented | | `client-credentials` | supported by every identity backend; the preferred CI method | implemented | | `pat` | only for products that accept product-issued long-lived tokens | validates, refuses at use with `auth.kind_not_implemented` | -The last column is the first `wso2 login` slice, not a property of the kind. A +The last column is what the shell implements, not a property of the kind. A document naming a deferred kind loads and validates — that is deliberate, so configuration written ahead of the shell stays readable — and refuses only when -an identity using it is actually selected. Examples below that use those kinds +an identity using it is actually selected. Examples below that use `pat` therefore describe intended shape, not something to run today. Browser and device are **login modes for one interactive OIDC identity**, not @@ -450,6 +450,14 @@ two stored kinds. `oauth-device` appears as a kind only where an identity can *only* be established that way; otherwise the mode is chosen at login with `--device-code`. +That flag is not in this release. Until it arrives, an identity that could be +established either way declares `oauth-browser` and is established that way, and +`oauth-device` is the kind for an identity where the browser mode is not +available at all — a deployment that cannot register the loopback callback URLs, +or one whose users are only ever on machines with no reachable browser. A +developer who merely *happens* to be on a headless machine today is served by a +second identity, not by this kind; that is the gap `--device-code` closes. + ### The adapter tier A kind is first-class only if the shell can derive short-lived, non-renewable diff --git a/docs/guides/login.md b/docs/guides/login.md index 086fb0d..1b83019 100644 --- a/docs/guides/login.md +++ b/docs/guides/login.md @@ -54,6 +54,10 @@ clicking is for. opaque token, and it refuses rather than hand over a grant it could not check. See `auth.narrowing_unavailable` in section 8. +A sixth is optional and needed only for logging in from a machine with no +browser: **the device code grant**. Section 5.1 covers it, and nothing else in +the registration changes. + --- ## 2. Register the application in Asgardeo @@ -460,7 +464,7 @@ For an Identity Server deployment, also set `"type": "onprem"` and use the | `defaultContext` | The context used when no `--context` flag and no `WSO2_CONTEXT` is given. Must name a context declared below. | | `identities[].name` | Lower-case letters, digits and dashes, starting with a letter, up to 64 characters. | | `identities[].type` | `cloud` or `onprem`. Nothing else is accepted. | -| `auth.kind` | `oauth-browser` for a person at a browser. `client-credentials` for CI — see section 7. `oauth-device` and `pat` are named by the schema but not implemented in this release. | +| `auth.kind` | `oauth-browser` for a person at a browser. `oauth-device` for an identity that can only be established without one — see section 5.1. `client-credentials` for CI — see section 7. `pat` is named by the schema but not implemented in this release. | | `auth.issuer` | The issuer, verbatim from its discovery document. | | `auth.clientId` | The registered public client. | | `auth.tenant` | The identity's home organization. | @@ -516,6 +520,92 @@ a missing desktop. The command waits up to five minutes for you. +## 5.1 Logging in without a browser + +If the machine you are typing on has no browser that can reach it — you are over +SSH, or inside a container — the login above cannot finish. It waits for the +identity provider to redirect back to `127.0.0.1` on *this* machine, and your +browser's `127.0.0.1` is somewhere else. + +The device authorization grant solves that. Nothing is bound to loopback, and +the approval happens on any other device you like. + +**When to use it.** Set `"kind": "oauth-device"` on the identity when that +identity can *only* be established this way — a deployment where the loopback +callback URLs cannot be registered, or one whose users are never at a machine +with a reachable browser. It is a property of the identity, not of where you +happen to be sitting today. + +If you are usually at a laptop and occasionally on a build box, that is the case +`wso2 login --device-code` is meant for, and **that flag is not in this +release**. Until it arrives, the way to have both is two identities — one +`oauth-browser`, one `oauth-device` — with different `credentialRef` values, and +a context for each. + +**What to register.** Everything from section 2 or 3 applies unchanged, with two +differences: + +- Add the **Device Code** grant to the application's allowed grant types. + Asgardeo and Identity Server 7.x both support it; on Asgardeo it appears in + the same **Allowed grant types** list as Code and Refresh Token. +- The four loopback callback URLs are not used by this flow. Leave them + registered anyway if the same application also serves browser logins. + +Thunder-backed products cannot use this flow at all — Thunder registers no +device grant handler, so its deployments advertise none and the shell refuses +before printing anything. + +**The context document** is the section 4.2 document with one word changed: + +```json + "auth": { + "kind": "oauth-device", + "issuer": "https://api.asgardeo.io/t/acme/oauth2/token", + "clientId": "REPLACE_WITH_YOUR_CLIENT_ID", + "tenant": "acme", + "credentialRef": "acme-cloud-device" + } +``` + +Every other field means exactly what it means for `oauth-browser`, and +`credentialRef` is required in the same way. Give it a different value from your +browser identity's if you keep both, so the two sessions do not share a slot. + +**What you see:** + +``` +$ wso2 login + +To log in, visit: + + https://api.asgardeo.io/t/acme/authenticationendpoint/device.do + +and enter the code: + + WDJB-MJHT + +Or open this link, which carries the code: + + https://api.asgardeo.io/t/acme/authenticationendpoint/device.do?user_code=WDJB-MJHT + +Waiting for you to approve this login... +``` + +Open the first URL on your phone or your laptop, type the code, and sign in. The +terminal finishes on its own. The third line is a shortcut for a device you can +paste a link into; the code is deliberately printed on its own line so it +survives being read aloud. + +The shell polls at the rate the deployment asks for and stops when the code +expires — usually after ten to fifteen minutes, and never later than fifteen. +Nothing is opened on this machine. + +**One difference from browser login worth knowing.** A browser login always +reports a `Subject`. A device login reports one only if the deployment returned +an identity token from this grant, which not every deployment does; RFC 8628 +does not require it. The session is established either way, and every product +command afterwards behaves identically. + --- ## 6. What login stored, and where @@ -710,6 +800,15 @@ usable. In order of likelihood: - **The issuer does not advertise `S256`.** Set PKCE to mandatory on the application, as in section 2.2. +There is a third, on a device login only: + +> the identity provider does not advertise the device authorization grant + +The deployment does not offer the grant, so there is no point printing a code +nobody could approve. Either enable the **Device Code** grant on the +application (section 5.1), or use an `oauth-browser` context. Thunder-backed +deployments have no device grant at all and cannot be made to. + There is a second, differently worded `auth.discovery_failed`: > no loopback callback port is available for the browser login @@ -796,6 +895,18 @@ On a browser login: the flow ended without producing tokens — you closed the browser, the consent was denied, or the deployment redirected back with an error. +On a device login (section 5.1), the message says which of four endings it was: + +| The message says | What it means | What to do | +| --- | --- | --- | +| "the login was declined at the identity provider" | You, or someone at the approval screen, refused the request. | Run `wso2 login` again and approve it. Check the code on screen matches the one in your terminal. | +| "the approval window closed before this login was approved" | The device code expired before anyone approved it. | Run `wso2 login` again and approve it promptly. | +| "this login was not approved in time" | The same, reached by the shell's own deadline rather than the deployment's answer. | As above. | +| "would not start a device authorization" | The deployment refused the request before any code was issued. | Confirm `clientId`, and that the application is registered for the device grant. | + +All four leave you in the same place — no session — which is why they share one +code. Only the sentence differs, because only the sentence can. + The browser reached "Login complete" and the code exchange succeeded, but the identity token that came back was not one the shell would accept. The message says which kind of failure it was: @@ -840,12 +951,14 @@ There is no session to establish; just run the command (section 7). ### `auth.non_interactive` `wso2 login` was run with `--non-interactive`, or with `WSO2_NON_INTERACTIVE` -set. This is the guard that stops a CI job from waiting on a browser forever. +set. This is the guard that stops a CI job from waiting on a browser forever — +or, on a device context, from waiting forever on an approval no one is there to +give. The message names which of the two it refused. ### `auth.kind_not_implemented` -The context's `auth.kind` is `oauth-device` or `pat`. The schema names them; this -release does not implement them. Use `oauth-browser` or `client-credentials`. +The context's `auth.kind` is `pat`. The schema names it; this release does not +implement it. Use `oauth-browser`, `oauth-device`, or `client-credentials`. ### `auth.session_issuer_mismatch` @@ -933,9 +1046,17 @@ before it reaches a browser. ```sh make smoke-login # log in, prove the session persisted, broker one acquisition +make smoke-login-device # the same, approved on another device (section 5.1) make empirical-asgardeo # answer the two open questions about Asgardeo's behavior ``` +`make smoke-login-device` reads the same variables and needs no new ones — the +only thing it wants from the deployment is the device grant enabled on the same +application. It also reports whether that deployment's device grant returned an +identity token, which is a per-deployment fact this repository has not yet +measured on either product; the answer belongs in the research document beside +the other verdicts. + A passing smoke run ends with the acquisition granted: ``` diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 8dac24b..65eab9e 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -163,11 +163,13 @@ contract is frozen. ### 7.2 Authentication and credentials > **What ships today.** These are requirements on the product, not a description -> of the current build. The first `wso2 login` slice implements browser -> Authorization Code with PKCE and inline client credentials. Device -> authorization and personal access tokens are accepted as legal configuration -> and refuse at use with the stable code `auth.kind_not_implemented`; there is -> no `--device-code` flag yet. See [the login first slice](plans/login-first-slice.md). +> of the current build. The shell implements browser Authorization Code with +> PKCE, the Device Authorization Grant, and inline client credentials. Personal +> access tokens are accepted as legal configuration and refuse at use with the +> stable code `auth.kind_not_implemented`. Device authorization is selected by +> an identity's `oauth-device` kind; there is no `--device-code` flag yet, so +> the requirement below that it be a login-time mode for a browser identity is +> not yet met. See [the login first slice](plans/login-first-slice.md). - **P0:** The root shell owns authentication sessions and credential storage. - **P0:** An **identity** is one login session together with every product for From aed330d22470e013206792f54fdd46105138b206 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Fri, 7 Aug 2026 10:11:42 +0530 Subject: [PATCH 6/7] fix: address review on the device login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from review on #44, all valid. The fake issuer left an approved device grant redeemable. A real deployment spends the code, this file's own exchangeCode already does, and the fixture is the oracle the device tests read "the session came from one approval" off — one that answered a replay would have let a real double-redemption defect through. The grant is now marked redeemed under the same lock that draws its counters down, and a later poll is answered invalid_grant. Two fixture tests pin it. The back-off assertion required five seconds where the property is the advertised interval plus five. With a 1s interval a client that replaced it with 5s rather than increasing it by 5s would have passed. It now requires 6s, written as advertised+increment so the number explains itself. The credentialRef row named only oauth-browser, though the schema has required it for both interactive kinds since before this branch and section 5.1 says so. The device endings table sat between the browser paragraph and its own continuation, so a reader passed from device endings straight back into browser narrative. It moves below the browser identity-token material, leaving each mode as one block. Also renamed TestBrowserSource* to TestSessionSource*, a leftover from renaming the type they cover. Declined: adding a `text` language to one fenced block for markdownlint MD040. Nothing in this repository runs markdownlint — CI is golangci-lint only — and 25 fenced blocks in that same file already carry no language, so tagging one would be inconsistent rather than correct. Refs #42 --- docs/guides/login.md | 26 ++++----- internal/auth/fakeissuer/fakeissuer.go | 32 +++++++++--- internal/auth/fakeissuer/fakeissuer_test.go | 58 +++++++++++++++++++++ internal/auth/source_session_test.go | 14 ++--- test/acceptance/login_device_test.go | 15 ++++-- 5 files changed, 112 insertions(+), 33 deletions(-) diff --git a/docs/guides/login.md b/docs/guides/login.md index 1b83019..2dc7867 100644 --- a/docs/guides/login.md +++ b/docs/guides/login.md @@ -468,7 +468,7 @@ For an Identity Server deployment, also set `"type": "onprem"` and use the | `auth.issuer` | The issuer, verbatim from its discovery document. | | `auth.clientId` | The registered public client. | | `auth.tenant` | The identity's home organization. | -| `auth.credentialRef` | The name the session is stored under in the OS secure store. **Required** for `oauth-browser`; **not allowed** for `client-credentials`. Same character rules as an identity name. | +| `auth.credentialRef` | The name the session is stored under in the OS secure store. **Required** for `oauth-browser` and `oauth-device`; **not allowed** for `client-credentials`. Same character rules as an identity name. | | `products.` | What this identity may reach for one module. The namespace is the module's own name, and follows the same character rules as an identity name. | | `products..endpoint` | The product's base URL. **Required** on every product entry, and must be an absolute `http` or `https` URL with a host. | | `products..audience` | What the issued token's `aud` claim must carry. A module asking for any other audience is refused. Conceptually this is the API resource identifier — but on Asgardeo it must be **the client ID**, because that is the only thing Asgardeo puts in `aud`. See section 2.5. | @@ -895,18 +895,6 @@ On a browser login: the flow ended without producing tokens — you closed the browser, the consent was denied, or the deployment redirected back with an error. -On a device login (section 5.1), the message says which of four endings it was: - -| The message says | What it means | What to do | -| --- | --- | --- | -| "the login was declined at the identity provider" | You, or someone at the approval screen, refused the request. | Run `wso2 login` again and approve it. Check the code on screen matches the one in your terminal. | -| "the approval window closed before this login was approved" | The device code expired before anyone approved it. | Run `wso2 login` again and approve it promptly. | -| "this login was not approved in time" | The same, reached by the shell's own deadline rather than the deployment's answer. | As above. | -| "would not start a device authorization" | The deployment refused the request before any code was issued. | Confirm `clientId`, and that the application is registered for the device grant. | - -All four leave you in the same place — no session — which is why they share one -code. Only the sentence differs, because only the sentence can. - The browser reached "Login complete" and the code exchange succeeded, but the identity token that came back was not one the shell would accept. The message says which kind of failure it was: @@ -943,6 +931,18 @@ curl -s "$(curl -s /.well-known/openid-configuration | python3 -c 'impor A serial printed as, for example, `serial=-3A4F8369` is that defect. It no longer stops a login. +On a device login (section 5.1), the message says which of four endings it was: + +| The message says | What it means | What to do | +| --- | --- | --- | +| "the login was declined at the identity provider" | You, or someone at the approval screen, refused the request. | Run `wso2 login` again and approve it. Check the code on screen matches the one in your terminal. | +| "the approval window closed before this login was approved" | The device code expired before anyone approved it. | Run `wso2 login` again and approve it promptly. | +| "this login was not approved in time" | The same, reached by the shell's own deadline rather than the deployment's answer. | As above. | +| "would not start a device authorization" | The deployment refused the request before any code was issued. | Confirm `clientId`, and that the application is registered for the device grant. | + +All four leave you in the same place — no session — which is why they share one +code. Only the sentence differs, because only the sentence can. + ### `auth.login_not_required` You ran `wso2 login` on a context whose identity carries its own credential. diff --git a/internal/auth/fakeissuer/fakeissuer.go b/internal/auth/fakeissuer/fakeissuer.go index e0d564e..dbe24b5 100644 --- a/internal/auth/fakeissuer/fakeissuer.go +++ b/internal/auth/fakeissuer/fakeissuer.go @@ -159,6 +159,10 @@ type Issuer struct { accessTokens map[string]tokenRecord // access token -> introspectable facts deviceGrants map[string]*deviceGrant devicePolls []time.Time + // lastDeviceCode is the most recently minted device code, recorded because + // map iteration order could not name "most recent" if a test ever started + // two authorizations. + lastDeviceCode string } type codeGrant struct { @@ -179,6 +183,10 @@ type deviceGrant struct { // concurrent tests cannot consume each other's waiting states. pending int slowDown int + // redeemed marks a grant whose approval has already produced tokens. The + // grant is kept rather than deleted so a later poll is answered the way a + // deployment answers a spent code, and so LastDeviceCode can still name it. + redeemed bool } type tokenRecord struct { @@ -610,6 +618,7 @@ func (i *Issuer) handleDeviceAuthorize(w http.ResponseWriter, r *http.Request) { pending: i.opts.DevicePendingPolls, slowDown: i.opts.DeviceSlowDownPolls, } + i.lastDeviceCode = deviceCode i.mutex.Unlock() expiresIn := i.opts.DeviceExpiresIn @@ -645,6 +654,14 @@ func (i *Issuer) deviceGrant(w http.ResponseWriter, r *http.Request) { i.mutex.Lock() i.devicePolls = append(i.devicePolls, time.Now()) grant, found := i.deviceGrants[r.PostForm.Get("device_code")] + // A grant already redeemed is treated as one that was never here. A device + // code is single-use, exactly as an authorization code is, and this fixture + // is the oracle the device tests read "the session came from one approval" + // off — one that answered a replay would let a real double-redemption + // defect through. + if found && grant.redeemed { + found = false + } var answer string if found { switch { @@ -660,12 +677,15 @@ func (i *Issuer) deviceGrant(w http.ResponseWriter, r *http.Request) { answer = "expired_token" } } - // The grant is read and its counters drawn down in one critical section, so - // two concurrent polls cannot both consume the last pending answer and both - // be approved. + // The grant is read, its counters drawn down, and its redemption recorded in + // one critical section, so two concurrent polls can neither both consume the + // last pending answer nor both be approved. scopes, clientID := []string(nil), "" if found { scopes, clientID = grant.scopes, grant.clientID + if answer == "" { + grant.redeemed = true + } } i.mutex.Unlock() @@ -721,11 +741,7 @@ func (i *Issuer) DevicePolls() []time.Time { func (i *Issuer) LastDeviceCode() string { i.mutex.Lock() defer i.mutex.Unlock() - // One login mints one code, which is every case that has a "most recent". - for code := range i.deviceGrants { - return code - } - return "" + return i.lastDeviceCode } // userCodeAlphabet is RFC 8628 section 6.1's recommended character set: upper diff --git a/internal/auth/fakeissuer/fakeissuer_test.go b/internal/auth/fakeissuer/fakeissuer_test.go index e940732..e9ebae6 100644 --- a/internal/auth/fakeissuer/fakeissuer_test.go +++ b/internal/auth/fakeissuer/fakeissuer_test.go @@ -461,3 +461,61 @@ func TestIntrospectionReportsForeignTokensInactive(t *testing.T) { t.Fatal("foreign token reported active") } } + +// deviceAuthorize starts one device authorization and returns its response. +func deviceAuthorize(t *testing.T, issuer *fakeissuer.Issuer) map[string]any { + t.Helper() + response, err := http.PostForm(issuer.URL+"/device_authorize", + url.Values{"client_id": {"client-123"}, "scope": {"openid"}}) + if err != nil { + t.Fatalf("device authorization request: %v", err) + } + defer func() { _ = response.Body.Close() }() + var body map[string]any + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatalf("device authorization decode: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("device authorization answered status=%d body=%v", response.StatusCode, body) + } + return body +} + +// pollDevice redeems a device code once. +func pollDevice(t *testing.T, issuer *fakeissuer.Issuer, deviceCode string) (map[string]any, int) { + t.Helper() + return token(t, issuer, url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {"client-123"}, + }) +} + +func TestADeviceCodeIsSpentByTheApprovalItCarries(t *testing.T) { + // The property the device tests read off this fixture is that a session + // came from one approval. An issuer that answered a replayed device code + // would satisfy a shell that redeemed the same approval twice, so the + // second redemption is refused here exactly as a deployment refuses it. + issuer := fakeissuer.New(t, fakeissuer.Options{Audience: "reference-status"}) + authorization := deviceAuthorize(t, issuer) + deviceCode := text(authorization, "device_code") + + granted, status := pollDevice(t, issuer, deviceCode) + if status != http.StatusOK || text(granted, "access_token") == "" { + t.Fatalf("the first redemption did not issue tokens: status=%d body=%v", status, granted) + } + + replayed, status := pollDevice(t, issuer, deviceCode) + if status != http.StatusBadRequest || text(replayed, "error") != "invalid_grant" { + t.Fatalf("a spent device code was redeemed a second time: status=%d body=%v", + status, replayed) + } +} + +func TestAnUnknownDeviceCodeIsInvalidGrant(t *testing.T) { + issuer := fakeissuer.New(t, fakeissuer.Options{}) + body, status := pollDevice(t, issuer, "never-issued") + if status != http.StatusBadRequest || text(body, "error") != "invalid_grant" { + t.Fatalf("unknown device code answered status=%d body=%v", status, body) + } +} diff --git a/internal/auth/source_session_test.go b/internal/auth/source_session_test.go index 4d5ab19..f7a1839 100644 --- a/internal/auth/source_session_test.go +++ b/internal/auth/source_session_test.go @@ -107,7 +107,7 @@ func (d browserDeployment) storedSession(t *testing.T) session.Session { return stored } -func TestBrowserSourceNarrowsTheSessionToWhatTheModuleAsked(t *testing.T) { +func TestSessionSourceNarrowsTheSessionToWhatTheModuleAsked(t *testing.T) { // The session holds two permissions and the module declares one. What // reaches the module must be the one it asked for: the issuer minted it, // it is bound to the product's audience, and it carries nothing more. @@ -133,7 +133,7 @@ func TestBrowserSourceNarrowsTheSessionToWhatTheModuleAsked(t *testing.T) { } } -func TestBrowserSourceStatesTheEffectiveScopesWhenTheIssuerDoesNot(t *testing.T) { +func TestSessionSourceStatesTheEffectiveScopesWhenTheIssuerDoesNot(t *testing.T) { // An issuer that answers a refresh without naming the effective scopes is // still provably narrowed: the access token itself carries the claim. deployment := seedBrowserSession(t, fakeissuer.Options{ @@ -151,7 +151,7 @@ func TestBrowserSourceStatesTheEffectiveScopesWhenTheIssuerDoesNot(t *testing.T) } } -func TestBrowserSourcePersistsTheRotatedRefreshTokenBeforeGranting(t *testing.T) { +func TestSessionSourcePersistsTheRotatedRefreshTokenBeforeGranting(t *testing.T) { // A rotating issuer invalidates the token it was presented. If the shell // granted access before storing the replacement, one crash would strand // the session; the next invocation proves the replacement was stored. @@ -181,7 +181,7 @@ func TestBrowserSourcePersistsTheRotatedRefreshTokenBeforeGranting(t *testing.T) } } -func TestBrowserSourceRefusesOnceTheRotatedTokenSupersedesTheStoredOne(t *testing.T) { +func TestSessionSourceRefusesOnceTheRotatedTokenSupersedesTheStoredOne(t *testing.T) { // The token the issuer replaced is dead. A session still holding it is a // session to log in again for, not one to keep retrying. deployment := seedBrowserSession(t, fakeissuer.Options{ @@ -204,7 +204,7 @@ func TestBrowserSourceRefusesOnceTheRotatedTokenSupersedesTheStoredOne(t *testin } } -func TestBrowserSourceRefusesRatherThanAcceptABroaderGrant(t *testing.T) { +func TestSessionSourceRefusesRatherThanAcceptABroaderGrant(t *testing.T) { // Narrowing that is ignored, refused, or answered against the wrong // audience all end the same way: no grant. A module that cannot be given // exactly what it asked for is given nothing. @@ -230,7 +230,7 @@ func TestBrowserSourceRefusesRatherThanAcceptABroaderGrant(t *testing.T) { } } -func TestBrowserSourceRefusesWhatNoSessionCanAnswer(t *testing.T) { +func TestSessionSourceRefusesWhatNoSessionCanAnswer(t *testing.T) { for name, testcase := range map[string]struct { prepare func(*testing.T, browserDeployment) code string @@ -295,7 +295,7 @@ func TestALoginRequiredRefusalNamesTheCommandThatFixesIt(t *testing.T) { } } -func TestNoBrowserRefusalCarriesSessionMaterial(t *testing.T) { +func TestNoRefusalCarriesSessionMaterial(t *testing.T) { // A refusal is rendered. The refresh token behind it is the one thing in // this flow that survives the command, so no refusal may repeat it. for name, testcase := range map[string]fakeissuer.Options{ diff --git a/test/acceptance/login_device_test.go b/test/acceptance/login_device_test.go index f281ffe..cd83a59 100644 --- a/test/acceptance/login_device_test.go +++ b/test/acceptance/login_device_test.go @@ -334,12 +334,17 @@ func TestADeviceLoginHonoursTheAdvertisedIntervalAndBacksOffWhenTold(t *testing. if first := polls[0].Sub(started); first < time.Second { t.Errorf("the first poll arrived after %v, sooner than the advertised 1s interval", first) } - // The back-off is the point: after slow_down the gap must exceed what was - // advertised, and by RFC 8628 section 3.5 it grows by five seconds. + // The back-off is the point, and the number to require is the advertised + // interval *plus* the increment, not the increment alone. RFC 8628 section + // 3.5 says the interval "MUST be increased by 5 seconds", so a client that + // merely replaced 1s with 5s would have ignored what the deployment + // advertised — and an assertion of five seconds would have let it. + const advertised, increment = time.Second, 5 * time.Second gap := polls[1].Sub(polls[0]) - if gap < 5*time.Second { - t.Errorf("the poll after slow_down came %v later; the interval did not grow by the "+ - "five seconds RFC 8628 requires", gap) + if gap < advertised+increment { + t.Errorf("the poll after slow_down came %v later, want at least %v; the advertised "+ + "interval was not increased by the five seconds RFC 8628 requires", + gap, advertised+increment) } } From a02c89ba2d3ad2d324d38942bd5a1089d4937547 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Fri, 7 Aug 2026 11:21:30 +0530 Subject: [PATCH 7/7] fix(auth): refuse a device interval the shell cannot act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment chooses the polling interval, and x/oauth2 substitutes RFC 8628's default only when the member is exactly zero — every other value reaches time.NewTicker, which panics on a non-positive duration. Two answers get there: a negative interval directly, and one above roughly nine billion seconds by overflowing the conversion to nanoseconds. Both were confirmed against the library. The cost is not tidiness. A panic escapes the problem type: it prints a Go stack trace instead of a refusal and exits outside the class list a script branches on, and nothing in main recovers. This shell renders typed problems, and what a deployment says must not be able to stop it doing that. So the advertised interval is sanitized before any of it is used. A value that is not a wait falls back to the specification's default; one past the ceiling is clamped rather than reinterpreted, because a deployment asking for an enormous wait has asked for something, and polling sooner than it consented to is the abuse RFC 8628 section 3.5 exists to prevent. The ceiling sits above any deadline a login can run under, which an internal test pins so the clamp can never start meaning "poll sooner". Severity is low — it needs a hostile or broken deployment, and the issuer is TLS-verified and named by the user's own context document. It is fixed because the fix is small and because "typed refusal, never a crash" is what this shell promises. Also from the same review pass: An identity token that is present and does not verify is now refused, where before it was reported as no identity at all. Absent and invalid are different answers. Tolerating silence is a judgement about an unmeasured protocol; tolerating a bad signature would be a judgement to stop looking — it means a client identifier naming another application, an issuer that did not sign what it sent, or a clock that disagrees, and each is a fault the user can fix and none is visible if the shell carries on quietly. Not a security boundary either way: an unverified token was never trusted. And the test gaps the review found: the non-interactive environment variable on a device identity, an absent verification_uri_complete, a device grant that returns no refresh token, and the report naming a subject it did verify. Refs #42 --- internal/app/login_test.go | 6 +- internal/auth/fakeissuer/fakeissuer.go | 47 ++++-- internal/auth/oauthflow/device.go | 85 +++++++--- .../auth/oauthflow/device_internal_test.go | 80 +++++++++ test/acceptance/login_device_test.go | 152 ++++++++++++++++++ 5 files changed, 334 insertions(+), 36 deletions(-) create mode 100644 internal/auth/oauthflow/device_internal_test.go diff --git a/internal/app/login_test.go b/internal/app/login_test.go index deadef4..d98b8b9 100644 --- a/internal/app/login_test.go +++ b/internal/app/login_test.go @@ -116,8 +116,12 @@ func TestLoginRefusals(t *testing.T) { []string{"--non-interactive"}, nil, "auth.login_not_required"}, // A device login is interactive too, so it is refused in CI for the // same reason a browser login is: nothing may wait on a human there. - {"non-interactive with a device identity", identityDoc(contexts.KindOAuthDevice), + // Both doors are checked, because a job that sets the variable rather + // than passing the flag is the commoner of the two. + {"non-interactive flag with a device identity", identityDoc(contexts.KindOAuthDevice), []string{"--non-interactive"}, nil, "auth.non_interactive"}, + {"non-interactive environment with a device identity", identityDoc(contexts.KindOAuthDevice), + nil, map[string]string{"WSO2_NON_INTERACTIVE": "1"}, "auth.non_interactive"}, {"personal access token kind", identityDoc(contexts.KindPAT), nil, nil, "auth.kind_not_implemented"}, } for _, testCase := range cases { diff --git a/internal/auth/fakeissuer/fakeissuer.go b/internal/auth/fakeissuer/fakeissuer.go index dbe24b5..73b79e2 100644 --- a/internal/auth/fakeissuer/fakeissuer.go +++ b/internal/auth/fakeissuer/fakeissuer.go @@ -119,7 +119,11 @@ type Options struct { DeviceSlowDownPolls int // DeviceInterval is the polling interval the device authorization response // advertises, in seconds. Zero leaves the member out entirely, which is how - // a test reaches the client's own default. + // a test reaches the client's own default. Any other value is sent + // verbatim, negative and absurd ones included: RFC 8628 constrains what a + // deployment should say and nothing constrains what one can say, and a + // client that carried such a value into its own arithmetic would fail in a + // way no refusal describes. DeviceInterval int // DeviceExpiresIn is the lifetime the device authorization response // advertises, in seconds. The default is 600, which is the order of @@ -139,6 +143,16 @@ type Options struct { // Whether Asgardeo and Identity Server return one from this grant is not // measured, so both answers are modeled rather than assumed. See issue #42. OmitDeviceIDToken bool + // OmitDeviceRefreshToken answers the device grant without a refresh token, + // modeling an application that was never granted offline access. A session + // is a refresh token, so this is the answer that produces a login with + // nothing to store. + OmitDeviceRefreshToken bool + // DeviceIDTokenAudience overrides the audience minted into the device + // grant's identity token. A value naming another application models the + // commonest real cause of a token that will not verify: a context document + // whose client identifier is not the one the deployment signed in. + DeviceIDTokenAudience string } // Issuer is one running fake issuer. Its URL doubles as the issuer identifier. @@ -637,8 +651,9 @@ func (i *Issuer) handleDeviceAuthorize(w http.ResponseWriter, r *http.Request) { // A zero interval is left out rather than sent as zero. RFC 8628 gives the // member a default precisely so a deployment may omit it, and a client that // reads a missing member as "poll as fast as you like" is a client this - // fixture exists to catch. - if i.opts.DeviceInterval > 0 { + // fixture exists to catch. Every other value, negative ones included, is + // sent exactly as the test asked for it. + if i.opts.DeviceInterval != 0 { response["interval"] = i.opts.DeviceInterval } writeJSON(w, http.StatusOK, response) @@ -708,19 +723,25 @@ func (i *Issuer) deviceGrant(w http.ResponseWriter, r *http.Request) { // none and a client that demanded one would be demanding something the flow // cannot supply. func (i *Issuer) issueDeviceTokens(w http.ResponseWriter, scopes []string, clientID string) { - refreshToken := randomToken("rt") - i.mutex.Lock() - i.refreshTokens[refreshToken] = scopes - i.mutex.Unlock() response := map[string]any{ - "access_token": i.mintAccessToken("user-1", scopes), - "token_type": "Bearer", - "expires_in": 300, - "refresh_token": refreshToken, - "scope": strings.Join(scopes, " "), + "access_token": i.mintAccessToken("user-1", scopes), + "token_type": "Bearer", + "expires_in": 300, + "scope": strings.Join(scopes, " "), + } + if !i.opts.OmitDeviceRefreshToken { + refreshToken := randomToken("rt") + i.mutex.Lock() + i.refreshTokens[refreshToken] = scopes + i.mutex.Unlock() + response["refresh_token"] = refreshToken } if !i.opts.OmitDeviceIDToken { - response["id_token"] = i.mintIDToken(clientID, "") + audience := clientID + if i.opts.DeviceIDTokenAudience != "" { + audience = i.opts.DeviceIDTokenAudience + } + response["id_token"] = i.mintIDToken(audience, "") } writeJSON(w, http.StatusOK, response) } diff --git a/internal/auth/oauthflow/device.go b/internal/auth/oauthflow/device.go index b1137b7..75fc027 100644 --- a/internal/auth/oauthflow/device.go +++ b/internal/auth/oauthflow/device.go @@ -105,6 +105,7 @@ func (d DeviceLogin) Run(ctx context.Context) (Result, error) { "Confirm the client identifier in the selected context, and that its OAuth application is "+ "registered for the device authorization grant, then retry wso2 login.") } + authorization.Interval = usableInterval(authorization.Interval) if err := d.present(authorization); err != nil { return Result{}, err } @@ -113,7 +114,43 @@ func (d DeviceLogin) Run(ctx context.Context) (Result, error) { if err != nil { return Result{}, approvalFailed(err) } - return d.identify(ctx, provider, token), nil + return d.identify(ctx, provider, token) +} + +const ( + // defaultPollIntervalSeconds is the interval RFC 8628 section 3.2 requires + // a client to assume when the deployment advertises none. + defaultPollIntervalSeconds = 5 + // maxPollIntervalSeconds is the longest advertised interval this shell will + // carry into its polling arithmetic. It is far beyond any deadline a login + // runs under, so clamping here cannot make the shell poll sooner than a + // deployment asked: an interval this long means the code expires before a + // single poll either way. + maxPollIntervalSeconds = 3600 +) + +// usableInterval replaces a polling interval this shell cannot act on. +// +// RFC 8628 lets the deployment choose the interval, and x/oauth2 substitutes +// the specification's default only when the member is exactly zero. Every other +// unusable value is carried into time.NewTicker, which panics on a non-positive +// duration — so a deployment answering with a negative interval, or one large +// enough to overflow the conversion to nanoseconds, would take the shell down +// with a stack trace. +// +// That matters beyond tidiness. A panic escapes the problem type entirely: it +// prints a Go stack trace rather than a refusal, and it exits with a code +// outside the class list a script branches on. This shell renders typed +// problems; what a deployment says must not be able to stop it doing that. +func usableInterval(advertised int64) int64 { + switch { + case advertised <= 0: + return defaultPollIntervalSeconds + case advertised > maxPollIntervalSeconds: + return maxPollIntervalSeconds + default: + return advertised + } } // present writes the two values the user has to carry to another device. @@ -149,34 +186,38 @@ func (d DeviceLogin) present(authorization *oauth2.DeviceAuthResponse) error { // identify reads who the login proved you are. // -// Unlike the browser login, a device login is not refused for want of an -// identity token. The browser login can afford to refuse because the -// authorization code flow is defined to carry one and there is a nonce to check -// it against; RFC 8628 defines no nonce, and whether WSO2 deployments return an -// identity token from this grant is not measured. The session is the refresh -// token, so a login that produced one has produced everything the shell needs, -// and refusing over a claim nothing depends on would let an unmeasured -// behaviour decide whether this flow works at all. -// -// What binds the answer to this process is the device code: it was minted for -// this request and is spent by it. So the token is verified when it is there — -// the issuer's signature, and the audience naming this client — and the subject -// is simply absent when it is not. +// Absent and invalid are two different answers here, and the difference is the +// whole of this function. +// +// A *missing* identity token does not fail a device login, unlike a browser +// one. The browser login can afford to refuse because the authorization code +// flow is defined to carry one and there is a nonce to check it against; RFC +// 8628 defines no nonce, and whether WSO2 deployments return an identity token +// from this grant is not measured. The session is the refresh token, so a login +// that produced one has produced everything the shell needs, and refusing over +// a claim nothing depends on would let an unmeasured behaviour decide whether +// this flow works at all. What binds the answer to this process instead is the +// device code: it was minted for this request and is spent by it. +// +// A token that is *present and does not verify* is refused, exactly as the +// browser login refuses it. Nothing was wrong with the deployment's silence; +// something is wrong with its answer — a client identifier naming another +// application, an issuer that did not sign what it sent, a clock that disagrees. +// Every one of those is a fault the user can go and fix, and every one of them +// is invisible if the shell quietly reports no subject and carries on. Tolerating +// silence is a decision about an unmeasured protocol; tolerating a bad signature +// would be a decision to stop looking. func (d DeviceLogin) identify( ctx context.Context, provider *oidc.Provider, token *oauth2.Token, -) Result { +) (Result, error) { result := Result{Token: token} raw, _ := token.Extra("id_token").(string) if raw == "" { - return result + return result, nil } verified, err := provider.Verifier(&oidc.Config{ClientID: d.ClientID}).Verify(ctx, raw) if err != nil { - // An identity token that does not verify is reported as no identity at - // all rather than as a failed login, for the same reason the absent one - // is: nothing the session does depends on it. Claiming a subject the - // issuer's keys did not vouch for is the one thing that would be worse. - return result + return Result{}, identityNotVerified(err) } var claims struct { Email string `json:"email"` @@ -184,7 +225,7 @@ func (d DeviceLogin) identify( _ = verified.Claims(&claims) result.Subject = verified.Subject result.Email = claims.Email - return result + return result, nil } // approvalFailed reports a device authorization that ended without a token, and diff --git a/internal/auth/oauthflow/device_internal_test.go b/internal/auth/oauthflow/device_internal_test.go new file mode 100644 index 0000000..20058e2 --- /dev/null +++ b/internal/auth/oauthflow/device_internal_test.go @@ -0,0 +1,80 @@ +// 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 ( + "math" + "testing" + "time" +) + +// TestUsableIntervalRefusesWhatWouldNotBeADuration proves the shell does not +// carry a deployment's polling interval into its arithmetic unchecked. +// +// The values here are not hypothetical shapes. x/oauth2 substitutes RFC 8628's +// default only when the advertised interval is exactly zero, and hands +// everything else to time.NewTicker — which panics on a non-positive duration. +// A negative interval reaches it directly; an interval beyond nine billion +// seconds reaches it as a negative one, because the conversion to nanoseconds +// overflows int64. Either would replace a typed refusal with a stack trace and +// an exit code outside the class list. +func TestUsableIntervalRefusesWhatWouldNotBeADuration(t *testing.T) { + // The threshold the multiplication overflows past, computed rather than + // written down so it cannot drift from the type it describes. + overflowing := int64(math.MaxInt64/int64(time.Second)) + 1 + + for name, testcase := range map[string]struct { + advertised int64 + want int64 + }{ + "absent is the specification's default": {advertised: 0, want: defaultPollIntervalSeconds}, + "negative is not a wait": {advertised: -1, want: defaultPollIntervalSeconds}, + "deeply negative is not a wait": {advertised: math.MinInt64, want: defaultPollIntervalSeconds}, + "an overflowing interval is clamped": {advertised: overflowing, want: maxPollIntervalSeconds}, + "the largest possible is clamped": {advertised: math.MaxInt64, want: maxPollIntervalSeconds}, + "a sane interval is honoured": {advertised: 7, want: 7}, + "the ceiling itself is honoured": {advertised: maxPollIntervalSeconds, want: maxPollIntervalSeconds}, + } { + t.Run(name, func(t *testing.T) { + got := usableInterval(testcase.advertised) + if got != testcase.want { + t.Fatalf("usableInterval(%d) = %d, want %d", testcase.advertised, got, testcase.want) + } + // The property behind every row: whatever comes back must be a + // duration a ticker will accept, which is what the panic was. + if duration := time.Duration(got) * time.Second; duration <= 0 { + t.Fatalf("usableInterval(%d) yields %v, which time.NewTicker panics on", + testcase.advertised, duration) + } + }) + } +} + +// TestTheClampNeverPollsSoonerThanADeploymentAsked guards the one way this +// clamp could do harm. Clamping an interval *down* would mean polling a +// deployment faster than it consented to, which is the abuse RFC 8628 section +// 3.5 exists to prevent — so the ceiling has to sit above any interval a login +// could actually act on. A login is bounded by its own deadline, and an +// interval longer than that produces no poll at all whether it is clamped or +// not. +func TestTheClampNeverPollsSoonerThanADeploymentAsked(t *testing.T) { + const longestLoginDeadline = 15 * time.Minute + if ceiling := time.Duration(maxPollIntervalSeconds) * time.Second; ceiling <= longestLoginDeadline { + t.Fatalf("the interval ceiling %v is within the %v a login may run for, so clamping "+ + "could make the shell poll sooner than a deployment asked", ceiling, longestLoginDeadline) + } +} diff --git a/test/acceptance/login_device_test.go b/test/acceptance/login_device_test.go index cd83a59..e0eec57 100644 --- a/test/acceptance/login_device_test.go +++ b/test/acceptance/login_device_test.go @@ -348,6 +348,158 @@ func TestADeviceLoginHonoursTheAdvertisedIntervalAndBacksOffWhenTold(t *testing. } } +func TestAHostileAdvertisedIntervalRefusesRatherThanCrashes(t *testing.T) { + // RFC 8628 lets the deployment set the polling interval, and nothing stops + // one answering with a value that is not a wait at all. Carried into the + // polling arithmetic, a negative interval — or one large enough to overflow + // the conversion to nanoseconds — panics: a stack trace instead of a + // refusal, and an exit code outside the class list a script branches on. + // + // The two cases end differently, and both endings are correct. + // + // A negative interval is not a request to wait at all, so the shell falls + // back to the specification's own default and the login completes. An + // interval past the overflow threshold is clamped to the ceiling instead of + // being reinterpreted, because a deployment asking for an enormous wait has + // asked for something — and what it asked for is longer than its own code + // lives, so no poll is due before the code expires. That is a refusal, and + // the right one: polling sooner than a deployment consented to is the abuse + // RFC 8628 section 3.5 exists to prevent. + // + // What both share is the property under test. Neither ends in a panic. + for name, testcase := range map[string]struct { + advertised int + expiresIn int + want exit.Code + }{ + "a negative interval falls back to the default": { + advertised: -1, want: exit.OK, + }, + "an overflowing interval is clamped, not reinterpreted": { + advertised: 10000000000, expiresIn: 2, want: exitAuthPolicy, + }, + } { + t.Run(name, func(t *testing.T) { + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: testcase.advertised, + DeviceExpiresIn: testcase.expiresIn, + DeviceOutcome: "approve", + }) + + if code := deployment.shell.Run([]string{"login"}); code != testcase.want { + t.Fatalf("wso2 login exited %d, want %d\nstderr:\n%s", + code, testcase.want, deployment.errOut) + } + // The tell for the defect this guards: a panic reaches the stream + // as a goroutine dump and exits outside the class list, so it can + // be neither rendered nor branched on. + if strings.Contains(deployment.errOut.String(), "panic") { + t.Errorf("the shell panicked on what the deployment advertised:\n%s", + deployment.errOut) + } + if testcase.want == exit.OK && deployment.storedSession(t).RefreshToken == "" { + t.Error("no session was stored") + } + }) + } +} + +func TestADeviceLoginRefusesAnIdentityTokenThatDoesNotVerify(t *testing.T) { + // Absent and invalid are different answers. A deployment that returns no + // identity token is tolerated, because RFC 8628 does not require one and + // the session does not depend on it. A deployment that returns one the + // shell cannot verify is refused, because something is wrong that the user + // can go and fix — here the commonest cause, a client identifier naming a + // different application than the one that signed them in. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: 1, + DeviceIDTokenAudience: "another-application", + }) + + if code := deployment.shell.Run([]string{"login"}); code != exitAuthPolicy { + t.Fatalf("wso2 login exited %d, want the authentication class %d\nstderr:\n%s", + code, exitAuthPolicy, deployment.errOut) + } + refusal := deployment.errOut.String() + if !strings.Contains(refusal, "issued for a different application") { + t.Errorf("the refusal does not name the cause the user can fix:\n%s", refusal) + } + // A refused login leaves nothing behind: a session stored here would be one + // the shell could not say whose it was. + if _, err := (session.Store{StateRoot: deployment.stateRoot}). + Load(loginCredentialRef); err == nil { + t.Error("a login whose identity did not verify stored a session anyway") + } +} + +func TestADeviceLoginWithoutARefreshTokenIsRefusedRatherThanStored(t *testing.T) { + // A session is a refresh token. A device login that produced none cannot be + // stored as one, and storing the access token alone would leave a session + // that expires in minutes and cannot renew itself. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: 1, + OmitDeviceRefreshToken: true, + }) + + if code := deployment.shell.Run([]string{"login"}); code != exitAuthPolicy { + t.Fatalf("wso2 login exited %d, want the authentication class %d\nstderr:\n%s", + code, exitAuthPolicy, deployment.errOut) + } + if !strings.Contains(deployment.errOut.String(), "offline_access") { + t.Errorf("the refusal does not name the scope that fixes it:\n%s", deployment.errOut) + } + if _, err := (session.Store{StateRoot: deployment.stateRoot}). + Load(loginCredentialRef); err == nil { + t.Error("a login that produced no refresh token stored a session anyway") + } +} + +func TestADeviceLoginNamesTheSubjectItVerified(t *testing.T) { + // The other half of the identity-token decision. Absence is tolerated and + // asserted elsewhere; here the deployment does return a verifiable token, + // and the report has to name who it proved you are — otherwise "reported + // when verified" would be satisfied by never reporting at all. + deployment := deviceDeployment(t, pollableDevice("approve", 0)) + + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + report := deployment.out.String() + if !strings.Contains(report, "Subject") || !strings.Contains(report, "user-1") { + t.Errorf("the report does not name the subject the login verified:\n%s", report) + } +} + +func TestADeviceLoginWorksWhereNoCompleteVerificationURIIsAdvertised(t *testing.T) { + // RFC 8628 makes verification_uri_complete optional, so a login must not + // depend on one. The two values a user actually needs are still printed, + // and nothing offers a link that was never advertised. + deployment := deviceDeployment(t, fakeissuer.Options{ + RefreshScopeMode: "honor", + DeviceInterval: 1, + OmitDeviceVerificationURIComplete: true, + }) + + if code := deployment.shell.Run([]string{"login"}); code != exit.OK { + t.Fatalf("wso2 login exited %d\nstderr:\n%s", code, deployment.errOut) + } + instructions := deployment.errOut.String() + if code := userCodePattern.FindString(instructions); code == "" || + !standsAlone(instructions, code) { + t.Errorf("the user code is not usable without a complete URI:\n%s", instructions) + } + if !standsAlone(instructions, deployment.issuer.URL+"/device") { + t.Errorf("the verification URI is not on a line of its own:\n%s", instructions) + } + if strings.Contains(instructions, "Or open this link") { + t.Errorf("a complete verification URI was offered that the deployment never advertised:\n%s", + instructions) + } +} + func TestADeviceLoginWithoutAnIdentityTokenStillEstablishesASession(t *testing.T) { // Deliberately unlike the browser login, which refuses without a verified // identity token. RFC 8628 defines no nonce and whether WSO2 deployments