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/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/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..2dc7867 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,11 +464,11 @@ 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. | -| `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. | @@ -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 @@ -832,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. @@ -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 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..d98b8b9 100644 --- a/internal/app/login_test.go +++ b/internal/app/login_test.go @@ -114,7 +114,14 @@ 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. + // 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/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/fakeissuer/fakeissuer.go b/internal/auth/fakeissuer/fakeissuer.go index d7c25bf..73b79e2 100644 --- a/internal/auth/fakeissuer/fakeissuer.go +++ b/internal/auth/fakeissuer/fakeissuer.go @@ -103,6 +103,56 @@ 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. 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 + // 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 + // 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. @@ -121,6 +171,12 @@ 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 + // 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 { @@ -131,6 +187,22 @@ 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 + // 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 { scopes []string audience string @@ -146,6 +218,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 +230,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 +241,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 +295,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 +447,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 +591,218 @@ 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.lastDeviceCode = deviceCode + 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. 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) +} + +// 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")] + // 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 { + 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, 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() + + 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) { + response := map[string]any{ + "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 { + audience := clientID + if i.opts.DeviceIDTokenAudience != "" { + audience = i.opts.DeviceIDTokenAudience + } + response["id_token"] = i.mintIDToken(audience, "") + } + 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() + return i.lastDeviceCode +} + +// 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 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/oauthflow/device.go b/internal/auth/oauthflow/device.go new file mode 100644 index 0000000..75fc027 --- /dev/null +++ b/internal/auth/oauthflow/device.go @@ -0,0 +1,286 @@ +// 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.") + } + authorization.Interval = usableInterval(authorization.Interval) + 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) +} + +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. +// +// 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. +// +// 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, error) { + result := Result{Token: token} + raw, _ := token.Extra("id_token").(string) + if raw == "" { + return result, nil + } + verified, err := provider.Verifier(&oidc.Config{ClientID: d.ClientID}).Verify(ctx, raw) + if err != nil { + return Result{}, identityNotVerified(err) + } + var claims struct { + Email string `json:"email"` + } + _ = verified.Claims(&claims) + result.Subject = verified.Subject + result.Email = claims.Email + return result, nil +} + +// 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/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/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 96% rename from internal/auth/source_browser_test.go rename to internal/auth/source_session_test.go index 4d5ab19..f7a1839 100644 --- a/internal/auth/source_browser_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 new file mode 100644 index 0000000..e0eec57 --- /dev/null +++ b/test/acceptance/login_device_test.go @@ -0,0 +1,533 @@ +// 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, 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 < 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) + } +} + +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 + // 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) + } +} 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") +}