Skip to main content

tuwunel_api/oidc/
token.rs

1use std::time::Duration;
2
3use axum::{
4	Json,
5	body::Body,
6	extract::{Form, State},
7	response::IntoResponse,
8};
9use http::{
10	Response, StatusCode,
11	header::{CACHE_CONTROL, PRAGMA},
12};
13use ruma::{OwnedDeviceId, UserId};
14use serde::Deserialize;
15use serde_json::json;
16use tuwunel_core::{
17	Err, Error, Result, err, info,
18	utils::{
19		BoolExt,
20		future::OptionFutureExt,
21		time::{now, timepoint_has_passed},
22	},
23	warn,
24};
25use tuwunel_service::{
26	Services,
27	oauth::server::{DeviceGrantPoll, IdTokenClaims, Server, narrow_scope},
28	users::device::{RefreshToken, generate_refresh_token},
29};
30
31use super::oauth_error;
32use crate::ClientIp;
33
34#[derive(Debug, Deserialize)]
35pub(crate) struct TokenRequest {
36	grant_type: String,
37	code: Option<String>,
38	redirect_uri: Option<String>,
39	client_id: Option<String>,
40	code_verifier: Option<String>,
41	refresh_token: Option<String>,
42	device_code: Option<String>,
43	#[serde(rename = "scope")]
44	_scope: Option<String>,
45}
46
47/// An authenticated grant ready to mint tokens.
48struct ApprovedGrant<'a> {
49	client_id: &'a str,
50	scope: &'a str,
51	user_id: &'a UserId,
52	nonce: Option<String>,
53	idp_id: Option<String>,
54}
55
56pub(crate) async fn token_route(
57	State(services): State<crate::State>,
58	ClientIp(client): ClientIp,
59	Form(body): Form<TokenRequest>,
60) -> impl IntoResponse {
61	// RFC 6749 §5.1 and §5.2 require Cache-Control: no-store and Pragma: no-cache
62	// on all token endpoint responses (success and error).
63	let inner = if services.oauth.check_rate_limit(client).is_err() {
64		oauth_error(StatusCode::TOO_MANY_REQUESTS, "slow_down", "Too many token requests")
65	} else {
66		match body.grant_type.as_str() {
67			| "authorization_code" => token_authorization_code(&services, &body)
68				.await
69				.unwrap_or_else(token_error_response),
70
71			| "refresh_token" => token_refresh(&services, &body)
72				.await
73				.unwrap_or_else(token_error_response),
74
75			| "urn:ietf:params:oauth:grant-type:device_code" =>
76				token_device_code(&services, &body)
77					.await
78					.unwrap_or_else(token_error_response),
79
80			| _ => oauth_error(
81				StatusCode::BAD_REQUEST,
82				"unsupported_grant_type",
83				"Unsupported grant_type",
84			),
85		}
86	};
87	let mut response = inner.into_response();
88	let headers = response.headers_mut();
89	headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("no-store"));
90	headers.insert(PRAGMA, http::HeaderValue::from_static("no-cache"));
91	response
92}
93
94async fn token_authorization_code(
95	services: &Services,
96	body: &TokenRequest,
97) -> Result<Response<Body>> {
98	let code = body
99		.code
100		.as_deref()
101		.ok_or_else(|| err!(Request(InvalidParam("code is required"))))?;
102
103	let redirect_uri = body
104		.redirect_uri
105		.as_deref()
106		.ok_or_else(|| err!(Request(InvalidParam("redirect_uri is required"))))?;
107
108	let client_id = body
109		.client_id
110		.as_deref()
111		.ok_or_else(|| err!(Request(InvalidParam("client_id is required"))))?;
112
113	let session = services
114		.oauth
115		.get_server()?
116		.exchange_auth_code(
117			code,
118			client_id,
119			redirect_uri,
120			body.code_verifier.as_deref(),
121			services.server.config.oidc_require_pkce,
122		)
123		.await?;
124
125	issue_tokens(services, ApprovedGrant {
126		client_id: &session.client_id,
127		scope: &session.scope,
128		user_id: &session.user_id,
129		nonce: session.nonce,
130		idp_id: session.idp_id,
131	})
132	.await
133}
134
135/// RFC 8628 §3.4: poll the device-code grant; pending, denied and expired map
136/// to the §3.5 error codes.
137async fn token_device_code(services: &Services, body: &TokenRequest) -> Result<Response<Body>> {
138	let device_code = body
139		.device_code
140		.as_deref()
141		.ok_or_else(|| err!(Request(InvalidParam("device_code is required"))))?;
142
143	let client_id = body
144		.client_id
145		.as_deref()
146		.ok_or_else(|| err!(Request(InvalidParam("client_id is required"))))?;
147
148	match services
149		.oauth
150		.get_server()?
151		.poll_device_grant(device_code, client_id)
152		.await?
153	{
154		| DeviceGrantPoll::Pending => Ok(oauth_error(
155			StatusCode::BAD_REQUEST,
156			"authorization_pending",
157			"The user has not yet completed authorization",
158		)),
159
160		| DeviceGrantPoll::Denied => Ok(oauth_error(
161			StatusCode::BAD_REQUEST,
162			"access_denied",
163			"The authorization request was denied",
164		)),
165
166		| DeviceGrantPoll::Expired => Ok(oauth_error(
167			StatusCode::BAD_REQUEST,
168			"expired_token",
169			"The device code has expired",
170		)),
171
172		| DeviceGrantPoll::Approved(grant) =>
173			issue_tokens(services, ApprovedGrant {
174				client_id: &grant.client_id,
175				scope: &grant.scope,
176				user_id: &grant.user_id,
177				nonce: None,
178				idp_id: grant.idp_id,
179			})
180			.await,
181	}
182}
183
184/// Mint the access token, refresh token and device for an authenticated grant,
185/// honoring the MSC2967 device scope and emitting the OAuth token response.
186async fn issue_tokens(services: &Services, grant: ApprovedGrant<'_>) -> Result<Response<Body>> {
187	let ApprovedGrant { client_id, scope, user_id, nonce, idp_id } = grant;
188
189	let (granted_scope, requested_device_id) =
190		narrow_scope(scope, services.server.config.oidc_strict_scope)?;
191
192	let requested_device: Option<OwnedDeviceId> = requested_device_id
193		.as_deref()
194		.map(OwnedDeviceId::from);
195
196	if requested_device.is_none() && services.server.config.oidc_require_device_scope {
197		return Err!(Request(InvalidParam(
198			"a device scope (urn:matrix:client:device:<id>) is required"
199		)));
200	}
201
202	let (access_token, expires_in) = services.users.generate_access_token(true);
203	let refresh_token = generate_refresh_token();
204	let client_name = services
205		.oauth
206		.get_server()?
207		.get_client(client_id)
208		.await
209		.ok()
210		.and_then(|c| c.client_name);
211
212	let device_display_name = client_name.as_deref().unwrap_or("OIDC Client");
213
214	let iss = services.oauth.get_server()?.issuer_url()?;
215	let id_token = granted_scope
216		.contains("openid")
217		.then(|| {
218			let now = now().as_secs();
219			let claims = IdTokenClaims {
220				iss,
221				sub: user_id.to_string(),
222				aud: client_id.to_owned(),
223				exp: now.saturating_add(3600),
224				iat: now,
225				nonce,
226				at_hash: Some(Server::at_hash(&access_token)),
227			};
228
229			services
230				.oauth
231				.get_server()?
232				.sign_id_token(&claims)
233		})
234		.transpose()?;
235
236	let device_id = services
237		.users
238		.create_device(
239			user_id,
240			requested_device.as_deref(),
241			(Some(&access_token), expires_in),
242			Some(&refresh_token),
243			Some(device_display_name),
244			None,
245		)
246		.await?;
247
248	// Tag the device with the IdP that authenticated it; a native (local
249	// account) grant carries no provider, so the device stays untagged.
250	if let Some(idp_id) = idp_id.filter(|idp| !idp.is_empty()) {
251		services
252			.users
253			.mark_oidc_device(user_id, &device_id, &idp_id);
254	}
255
256	info!("{user_id} logged in via OIDC on {device_id} ({device_display_name})");
257
258	// MSC2967: echo a server-chosen device id back in the scope when the client
259	// omitted one.
260	let scope = if requested_device.is_some() {
261		granted_scope
262	} else {
263		warn!(%user_id, %device_id, "OIDC client omitted the device scope; generated a device id");
264
265		let sep = if granted_scope.is_empty() { "" } else { " " };
266		format!("{granted_scope}{sep}urn:matrix:client:device:{device_id}")
267	};
268
269	let mut response = json!({
270		"access_token": access_token,
271		"refresh_token": refresh_token,
272		"scope": scope,
273		"token_type": "Bearer",
274	});
275
276	if let Some(id_token) = id_token {
277		response["id_token"] = json!(id_token);
278	}
279
280	if let Some(expires_in) = expires_in {
281		response["expires_in"] = json!(expires_in.as_secs());
282	}
283
284	Ok(Json(response).into_response())
285}
286
287async fn token_refresh(services: &Services, body: &TokenRequest) -> Result<Response<Body>> {
288	let presented = body
289		.refresh_token
290		.as_deref()
291		.ok_or_else(|| err!(Request(InvalidParam("refresh_token is required"))))?;
292
293	match services
294		.users
295		.classify_refresh_token(presented)
296		.await
297	{
298		| RefreshToken::Current { user_id, device_id, expires_at } => {
299			if expires_at.is_some_and(timepoint_has_passed) {
300				services
301					.server
302					.config
303					.refresh_token_hard_logout
304					.then_async(|| services.users.remove_device(&user_id, &device_id))
305					.unwrap_or_else_async(async || {
306						services
307							.users
308							.remove_refresh_token(&user_id, &device_id)
309							.await
310							.ok();
311					})
312					.await;
313
314				return Err!(Request(Forbidden("Refresh token has expired")));
315			}
316
317			let (access_token, expires_in) = services.users.generate_access_token(true);
318			let refresh_token = generate_refresh_token();
319			services
320				.users
321				.set_access_token(
322					&user_id,
323					&device_id,
324					&access_token,
325					expires_in,
326					Some(&refresh_token),
327				)
328				.await?;
329
330			token_refresh_response(&access_token, &refresh_token, expires_in)
331		},
332
333		| RefreshToken::Replayed { user_id, device_id, current, grace } if grace => {
334			// Benign double-submit: re-issue an access token for the unchanged
335			// refresh token rather than rotating it.
336			let (access_token, expires_in) = services.users.generate_access_token(true);
337			services
338				.users
339				.set_access_token(&user_id, &device_id, &access_token, expires_in, None)
340				.await?;
341
342			token_refresh_response(&access_token, &current, expires_in)
343		},
344
345		| RefreshToken::Replayed { user_id, device_id, .. } => {
346			let revoke = services.server.config.refresh_token_reuse_revoke;
347			warn!(%user_id, %device_id, revoke, "OIDC refresh token reused after rotation");
348
349			if revoke {
350				services
351					.users
352					.remove_device(&user_id, &device_id)
353					.await;
354			}
355
356			Err!(Request(Forbidden("Refresh token has already been used")))
357		},
358
359		| RefreshToken::Unknown => Err!(Request(Forbidden("Invalid refresh token"))),
360	}
361}
362
363fn token_refresh_response(
364	access_token: &str,
365	refresh_token: &str,
366	expires_in: Option<Duration>,
367) -> Result<Response<Body>> {
368	let mut response = json!({
369		"access_token": access_token,
370		"refresh_token": refresh_token,
371		"token_type": "Bearer",
372	});
373
374	if let Some(expires_in) = expires_in {
375		response["expires_in"] = json!(expires_in.as_secs());
376	}
377
378	Ok(Json(response).into_response())
379}
380
381/// RFC 6749 §5.2: map error to correct HTTP status and OAuth2 error code.
382/// Client-side errors (invalid grant, bad params) → 400 invalid_grant.
383/// Server-side errors → 500 server_error with sanitized message.
384#[expect(clippy::needless_pass_by_value)]
385fn token_error_response(e: Error) -> Response<Body> {
386	if !e.status_code().is_client_error() {
387		return oauth_error(
388			StatusCode::INTERNAL_SERVER_ERROR,
389			"server_error",
390			"An internal error occurred",
391		);
392	}
393
394	oauth_error(StatusCode::BAD_REQUEST, "invalid_grant", &e.sanitized_message())
395}