|
| 1 | +// OAuth Device Authorization Grant (RFC 8628) acceptance (real Zitadel). |
| 2 | +// Mints a real device_authorization, drives /id/device → consent → login ceremony → |
| 3 | +// post-login return-to-consent → approve, then proves the grant end-to-end by polling |
| 4 | +// the device token endpoint until it stops returning authorization_pending and issues |
| 5 | +// an access token. |
| 6 | +// |
| 7 | +// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel |
| 8 | +// against the local stack; the cypress process needs NODE_EXTRA_CA_CERTS=./dev-ca.crt so |
| 9 | +// cy.request can reach Zitadel over the self-signed dev CA. See the INDEX log (2026-06-13). |
| 10 | + |
| 11 | +const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1'; |
| 12 | + |
| 13 | +const ZITADEL = String(Cypress.env('ZITADEL_API_URL') ?? 'https://auth.localtest.me:30000'); |
| 14 | +// Device client `auth-ui-e2e-device` (device grant enabled; codes are throwaway). |
| 15 | +const CLIENT_ID = String(Cypress.env('DEVICE_CLIENT_ID') ?? '377158965391327283'); |
| 16 | +// user2 is the CLEAN password-only ceremony user (user3 has live TOTP enrolled). |
| 17 | +const LOGIN_NAME = String(Cypress.env('ACCEPTANCE_LOGIN_NAME') ?? 'zitadel-e2e-user2'); |
| 18 | +const PASSWORD = String(Cypress.env('ACCEPTANCE_PASSWORD') ?? 'LocalDev-Passw0rd!'); |
| 19 | + |
| 20 | +const TOKEN_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; |
| 21 | +// RFC 8628 §3.5 recommends a 5 s poll interval; we use 1 s against the local |
| 22 | +// Zitadel stack (fast, same machine). 10 attempts × 1 s = 10 s budget, which |
| 23 | +// comfortably covers slow container startup and consent propagation lag. |
| 24 | +const POLL_ATTEMPTS = 10; |
| 25 | +const POLL_DELAY_MS = 1000; |
| 26 | + |
| 27 | +/** Poll the device token endpoint until it issues a token (or attempts run out). */ |
| 28 | +function pollToken(deviceCode: string, attempt: number): void { |
| 29 | + cy.request({ |
| 30 | + method: 'POST', |
| 31 | + url: `${ZITADEL}/oauth/v2/token`, |
| 32 | + form: true, |
| 33 | + body: { grant_type: TOKEN_GRANT, device_code: deviceCode, client_id: CLIENT_ID }, |
| 34 | + failOnStatusCode: false, |
| 35 | + }).then((resp) => { |
| 36 | + if (resp.status === 200 && resp.body.access_token) { |
| 37 | + expect(resp.body.access_token, 'device grant issued an access token').to.be.a('string'); |
| 38 | + return; |
| 39 | + } |
| 40 | + expect(resp.body.error, 'pending until consent lands').to.eq('authorization_pending'); |
| 41 | + expect(attempt, 'token endpoint never issued a token').to.be.lessThan(POLL_ATTEMPTS); |
| 42 | + cy.wait(POLL_DELAY_MS); |
| 43 | + pollToken(deviceCode, attempt + 1); |
| 44 | + }); |
| 45 | +} |
| 46 | + |
| 47 | +(RUN ? describe : describe.skip)('Device authorization grant (real Zitadel)', () => { |
| 48 | + it('user code → login ceremony → consent approve → token endpoint issues a token', () => { |
| 49 | + cy.request({ |
| 50 | + method: 'POST', |
| 51 | + url: `${ZITADEL}/oauth/v2/device_authorization`, |
| 52 | + form: true, |
| 53 | + body: { client_id: CLIENT_ID, scope: 'openid' }, |
| 54 | + }).then((mint) => { |
| 55 | + expect(mint.status).to.eq(200); |
| 56 | + const userCode = String(mint.body.user_code); |
| 57 | + const deviceCode = String(mint.body.device_code); |
| 58 | + |
| 59 | + // 1) user enters the code shown on the device |
| 60 | + cy.visit('/id/device'); |
| 61 | + cy.get('input[name="userCode"]').type(userCode); |
| 62 | + cy.get('button[type="submit"]').first().click(); |
| 63 | + |
| 64 | + // 2) consent screen resolves the device auth; requestId carries the stable user code |
| 65 | + cy.location('pathname').should('eq', '/id/device/authorize'); |
| 66 | + cy.location('search').should('include', `user_code=${userCode}`); |
| 67 | + |
| 68 | + // 3) approving without a session bounces through the login ceremony |
| 69 | + cy.get('button[name="decision"][value="authorize"]').click(); |
| 70 | + cy.location('pathname').should('eq', '/id/login'); |
| 71 | + cy.location('search').should('include', `requestId=device_${userCode}`); |
| 72 | + |
| 73 | + cy.get('input[name="loginName"]').type(LOGIN_NAME); |
| 74 | + cy.get('button[type="submit"]').first().click(); |
| 75 | + |
| 76 | + cy.location('pathname').should('eq', '/id/login/password'); |
| 77 | + cy.get('input[name="password"]').type(PASSWORD, { log: false }); |
| 78 | + cy.get('button[type="submit"]').first().click(); |
| 79 | + |
| 80 | + // 4) post-password: either the skippable MFA prompt or straight back to consent |
| 81 | + cy.location('pathname', { timeout: 10000 }).should( |
| 82 | + 'match', |
| 83 | + /\/id\/(setup\/mfa|device\/authorize)$/ |
| 84 | + ); |
| 85 | + cy.location('pathname').then((path) => { |
| 86 | + if (path === '/id/setup/mfa') { |
| 87 | + cy.contains('button', /skip for now/i).click(); |
| 88 | + } |
| 89 | + }); |
| 90 | + |
| 91 | + // 5) post-login return-to-consent: the ceremony threads device_<userCode> back |
| 92 | + // through /authorize, which re-derives ?user_code= for the consent screen |
| 93 | + cy.location('pathname', { timeout: 10000 }).should('eq', '/id/device/authorize'); |
| 94 | + cy.location('search').should('include', `user_code=${userCode}`); |
| 95 | + |
| 96 | + // 6) approve with the signed-in session → in-page completion state |
| 97 | + cy.get('button[name="decision"][value="authorize"]').click(); |
| 98 | + cy.contains('You may return to your device', { timeout: 10000 }).should('be.visible'); |
| 99 | + |
| 100 | + // 7) end-to-end proof: the token endpoint flips from authorization_pending to a token |
| 101 | + pollToken(deviceCode, 1); |
| 102 | + }); |
| 103 | + }); |
| 104 | +}); |
| 105 | + |
| 106 | +// Module scope: prevents top-level const collisions across acceptance specs under tsc. |
| 107 | +export {}; |
0 commit comments