diff --git a/src/content/docs.md b/src/content/docs.md index 24d369d..edb22d0 100644 --- a/src/content/docs.md +++ b/src/content/docs.md @@ -229,6 +229,8 @@ Indiko provides a complete OAuth 2.0 token management system with access tokens, ### refresh tokens +A refresh token is only issued when your app requests the `offline_access` scope at authorization time. If you don't request it, the grant is one-shot: you get an access token (1 hour) and nothing more. See [scopes](#scopes). + Exchange a refresh token for a new access token (and a new rotated refresh token): ```http @@ -249,13 +251,15 @@ Response: "expires_in": 3600, "refresh_token": "RT_new_rotated_token...", "me": "{{origin}}/u/username", - "scope": "profile email", + "scope": "profile email offline_access", "iss": "{{origin}}" } ``` > **Token rotation:** Refresh tokens are rotated on every use. The old refresh token is invalidated when a new one is issued. Always store and use the latest refresh token from the response. +> **Reuse detection (RFC 9700):** If a refresh token that was already rotated is presented again, Indiko treats it as a possible token leak and **revokes the entire token family** — the current access and refresh tokens in that chain stop working immediately. This means a stolen refresh token can't be replayed without killing the session it came from. Well-behaved clients never replay an old token, so this only fires on a race or a leak; if it fires for you, re-authorize and check how the token was stored. + ### token introspection Verify an access token and get its metadata: @@ -406,9 +410,12 @@ Scopes control what data your app can access: | `profile` | Access to user profile (name, photo, URL) — **always required** | | `email` | Access to user email address | | `openid` | Request an OIDC ID token | +| `offline_access` | Request a refresh token for long-lived access | Users can uncheck optional scopes during authorization. The `profile` scope is always granted. +> **offline_access:** Request this only if your app needs to act on the user's behalf after the access token expires. Without it you get a 1-hour access token and no refresh token. Users see it on the consent screen as a distinct "keep you signed in long-term" permission. + > **Scope handling:** Requested scopes appear as checkboxes on the consent screen. Users can deny optional scopes, and your app receives only the approved subset in the token response. ## roles diff --git a/src/oidc.ts b/src/oidc.ts index 05f2d6e..b9fe783 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -143,7 +143,7 @@ export function getDiscoveryDocument(origin: string) { token_endpoint: `${origin}/auth/token`, userinfo_endpoint: `${origin}/userinfo`, jwks_uri: `${origin}/jwks`, - scopes_supported: ["openid", "profile", "email"], + scopes_supported: ["openid", "profile", "email", "offline_access"], response_types_supported: ["code"], grant_types_supported: [ "authorization_code", diff --git a/src/routes/oauth/discovery.ts b/src/routes/oauth/discovery.ts index 269c64b..f6b24b4 100644 --- a/src/routes/oauth/discovery.ts +++ b/src/routes/oauth/discovery.ts @@ -109,7 +109,7 @@ export function indieauthMetadata(): Response { userinfo_endpoint: `${origin}/userinfo`, jwks_uri: `${origin}/jwks`, code_challenge_methods_supported: ["S256"], - scopes_supported: ["profile", "email"], + scopes_supported: ["profile", "email", "offline_access"], response_types_supported: ["code"], grant_types_supported: [ "authorization_code", diff --git a/src/routes/oauth/token.ts b/src/routes/oauth/token.ts index 571bee0..7a1f5f2 100644 --- a/src/routes/oauth/token.ts +++ b/src/routes/oauth/token.ts @@ -293,8 +293,10 @@ async function handleDeviceCodeGrant( const accessToken = generateToken(); const expiresAt = now + ACCESS_TOKEN_TTL; - const refreshToken = generateToken(); - const refreshExpiresAt = now + REFRESH_TOKEN_TTL; + + const issueRefresh = scopes.includes("offline_access"); + const refreshToken = issueRefresh ? generateToken() : null; + const refreshExpiresAt = issueRefresh ? now + REFRESH_TOKEN_TTL : null; db.query( "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", @@ -319,19 +321,21 @@ async function handleDeviceCodeGrant( profile.email = user.email; } - return Response.json( - { - access_token: accessToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL, - refresh_token: refreshToken, - me: meValue, - profile, - scope: deviceCode.scope, - iss: origin, - }, - { headers: NO_STORE_HEADERS }, - ); + const deviceResponse: Record = { + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL, + me: meValue, + profile, + scope: deviceCode.scope, + iss: origin, + }; + + if (refreshToken) { + deviceResponse.refresh_token = refreshToken; + } + + return Response.json(deviceResponse, { headers: NO_STORE_HEADERS }); } // Verify pre-registered client credentials; returns a Response on failure @@ -532,8 +536,12 @@ async function handleAuthorizationCodeGrant( const accessToken = generateToken(); const expiresAt = now + ACCESS_TOKEN_TTL; - const refreshToken = generateToken(); - const refreshExpiresAt = now + REFRESH_TOKEN_TTL; + + // Only issue a refresh token when the client requested offline_access + // (OIDC Core §11). Otherwise this is a one-shot grant, access token only. + const issueRefresh = scopes.includes("offline_access"); + const refreshToken = issueRefresh ? generateToken() : null; + const refreshExpiresAt = issueRefresh ? now + REFRESH_TOKEN_TTL : null; db.query( "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", @@ -552,7 +560,6 @@ async function handleAuthorizationCodeGrant( access_token: accessToken, token_type: "Bearer", expires_in: ACCESS_TOKEN_TTL, - refresh_token: refreshToken, me: meValue, profile, scope: scopes.join(" "), @@ -563,6 +570,10 @@ async function handleAuthorizationCodeGrant( response.refresh_token = refreshToken; } + if (refreshToken) { + response.refresh_token = refreshToken; + } + if (permission?.role) { response.role = permission.role; } diff --git a/test/device.test.ts b/test/device.test.ts index 5f4a845..d22af00 100644 --- a/test/device.test.ts +++ b/test/device.test.ts @@ -30,7 +30,7 @@ function seedApp() { async function requestDeviceCode( clientId = CLIENT_ID, - scope = "profile", + scope = "profile offline_access", ): Promise<{ device_code: string; user_code: string; @@ -120,7 +120,7 @@ describe("POST /auth/device", () => { test("stores device code in database", async () => { seedApp(); - const body = await requestDeviceCode(); + const body = await requestDeviceCode(CLIENT_ID, "profile"); const row = db .query( @@ -210,7 +210,7 @@ describe("token endpoint: device_code grant", () => { expect(body.token_type).toBe("Bearer"); expect(body.refresh_token).toBeString(); expect(body.me).toContain("/u/kieran"); - expect(body.scope).toBe("profile"); + expect(body.scope).toBe("profile offline_access"); // Device code should be cleaned up (single use) const row = db diff --git a/test/token.test.ts b/test/token.test.ts index 10b802b..5f2ec3a 100644 --- a/test/token.test.ts +++ b/test/token.test.ts @@ -145,7 +145,7 @@ describe("token endpoint: authorization_code grant", () => { test("happy path: issues access + refresh tokens, marks code used", async () => { const userId = createUser({ username: "kieran" }); seedApp(); - const code = seedAuthCode(userId, { scopes: ["profile"] }); + const code = seedAuthCode(userId, { scopes: ["profile", "offline_access"] }); const res = await token(tokenReq(exchangeBody(code))); expect(res.status).toBe(200); @@ -155,7 +155,7 @@ describe("token endpoint: authorization_code grant", () => { expect(body.token_type).toBe("Bearer"); expect(body.access_token).toBeString(); expect(body.refresh_token).toBeString(); - expect(body.scope).toBe("profile"); + expect(body.scope).toBe("profile offline_access"); expect(body.me).toContain("/u/kieran"); expect(body.profile.name).toBeString(); @@ -186,12 +186,37 @@ describe("token endpoint: authorization_code grant", () => { const second = await token(tokenReq(exchangeBody(code))); expect(second.status).toBe(400); }); + + test("no refresh token without offline_access scope", async () => { + const userId = createUser({}); + seedApp(); + const code = seedAuthCode(userId, { scopes: ["profile"] }); + + const res = await token(tokenReq(exchangeBody(code))); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.access_token).toBeString(); + expect(body.refresh_token).toBeUndefined(); + }); + + test("refresh token issued when offline_access scope requested", async () => { + const userId = createUser({}); + seedApp(); + const code = seedAuthCode(userId, { scopes: ["profile", "offline_access"] }); + + const res = await token(tokenReq(exchangeBody(code))); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.refresh_token).toBeString(); + }); }); describe("token endpoint: refresh_token grant", () => { async function issueTokens(userId: number) { seedApp(); - const code = seedAuthCode(userId); + const code = seedAuthCode(userId, { scopes: ["profile", "offline_access"] }); const res = await token(tokenReq(exchangeBody(code))); return (await res.json()) as { access_token: string; @@ -250,7 +275,7 @@ describe("token endpoint: refresh_token grant", () => { describe("token endpoint: refresh family detection (RFC 9700)", () => { async function issueTokens(userId: number) { seedApp(); - const code = seedAuthCode(userId); + const code = seedAuthCode(userId, { scopes: ["profile", "offline_access"] }); const res = await token(tokenReq(exchangeBody(code))); return (await res.json()) as { access_token: string;