tuwunel_service/oauth/
server.rs1mod auth;
2mod client;
3mod device;
4mod jwk;
5mod signing_key;
6mod token;
7
8use std::sync::Arc;
9
10use serde_json::Value as JsonValue;
11use tuwunel_core::{Err, Result, debug_info, debug_warn, err, implement, utils::MutexMap, warn};
12use tuwunel_database::Map;
13
14pub use self::{
15 auth::{AUTH_REQUEST_LIFETIME, AuthCodeSession, AuthRequest},
16 client::{ClientRegistration, DcrRequest},
17 device::{
18 ApprovedDeviceGrant, DEVICE_GRANT_INTERVAL_SECS, DEVICE_GRANT_LIFETIME, DeviceGrant,
19 DeviceGrantPoll, DeviceGrantStatus, format_user_code,
20 },
21 token::IdTokenClaims,
22};
23use self::{
24 jwk::init_jwk,
25 signing_key::{SigningKey, init_signing_key},
26};
27use crate::services::OnceServices;
28
29pub struct Server {
30 services: Arc<OnceServices>,
31 db: Data,
32 jwk: JsonValue,
33 key: SigningKey,
34
35 device_locks: MutexMap<String, ()>,
38}
39
40struct Data {
41 oidc_signingkey: Arc<Map>,
42 oidcclientid_registration: Arc<Map>,
43 oidccode_authsession: Arc<Map>,
44 oidcdevicecode_devicegrant: Arc<Map>,
45 oidcusercode_devicecode: Arc<Map>,
46 oidcreqid_authrequest: Arc<Map>,
47}
48
49impl Server {
50 pub(super) fn build(args: &crate::Args<'_>) -> Result<Option<Self>> {
51 if !Self::can_build(args) {
52 return Ok(None);
53 }
54
55 let db = Data {
56 oidc_signingkey: args.db["oidc_signingkey"].clone(),
57 oidcclientid_registration: args.db["oidcclientid_registration"].clone(),
58 oidccode_authsession: args.db["oidccode_authsession"].clone(),
59 oidcdevicecode_devicegrant: args.db["oidcdevicecode_devicegrant"].clone(),
60 oidcusercode_devicecode: args.db["oidcusercode_devicecode"].clone(),
61 oidcreqid_authrequest: args.db["oidcreqid_authrequest"].clone(),
62 };
63
64 let key = init_signing_key(&db)?;
65 debug_info!(
66 key = ?key.key_id,
67 "Initializing OIDC server for next-gen auth (MSC2965)"
68 );
69
70 Ok(Some(Self {
71 services: args.services.clone(),
72 db,
73 jwk: init_jwk(&key.key_der, &key.key_id)?,
74 key,
75 device_locks: MutexMap::new(),
76 }))
77 }
78}
79
80#[implement(Server)]
81fn can_build(args: &crate::Args<'_>) -> bool {
82 let has_idp = !args.server.config.identity_provider.is_empty();
83 let has_cwk = args.server.config.well_known.client.is_some();
84 let native = args.server.config.oidc_native_auth;
85
86 if (has_idp || native) && !has_cwk {
87 warn!("OIDC server (Next-gen auth) requires `well_known.client` to be configured.");
88
89 return false;
90 }
91
92 if !has_idp && !native {
93 debug_warn!(
94 "OIDC server (Next-gen auth) requires at least one `identity_provider`, or \
95 `oidc_native_auth` to be enabled."
96 );
97
98 return false;
99 }
100
101 true
102}
103
104#[implement(Server)]
105pub fn issuer_url(&self) -> Result<String> {
106 self.services
107 .config
108 .well_known
109 .client
110 .as_ref()
111 .map(|url| {
112 let s = url.to_string();
113
114 if s.ends_with('/') { s } else { format!("{s}/") }
115 })
116 .ok_or_else(|| {
117 err!(Config("well_known.client", "well_known.client must be set for OIDC server"))
118 })
119}
120
121const DEVICE_SCOPE_PREFIXES: [&str; 2] =
123 ["urn:matrix:client:device:", "urn:matrix:org.matrix.msc2967.client:device:"];
124
125const API_SCOPE_PREFIXES: [&str; 2] =
127 ["urn:matrix:client:api:", "urn:matrix:org.matrix.msc2967.client:api:"];
128
129pub fn narrow_scope(requested: &str, strict: bool) -> Result<(String, Option<String>)> {
136 let mut granted = String::new();
137 let mut device_id: Option<&str> = None;
138
139 for token in requested.split_whitespace() {
140 let keep = if let Some(id) = DEVICE_SCOPE_PREFIXES
141 .iter()
142 .find_map(|prefix| token.strip_prefix(prefix))
143 {
144 if device_id.is_some() {
145 return Err!(Request(InvalidParam("more than one device scope requested")));
146 }
147 if id.is_empty() || !id.bytes().all(is_scope_char) {
148 return Err!(Request(InvalidParam("device id contains an invalid character")));
149 }
150
151 device_id = Some(id);
152 true
153 } else {
154 token == "openid"
155 || API_SCOPE_PREFIXES
156 .iter()
157 .any(|prefix| token.starts_with(prefix))
158 };
159
160 if keep {
161 if !granted.is_empty() {
162 granted.push(' ');
163 }
164
165 granted.push_str(token);
166 } else if strict {
167 return Err!(Request(InvalidParam("unsupported scope requested")));
168 }
169 }
170
171 Ok((granted, device_id.map(ToOwned::to_owned)))
172}
173
174#[inline]
177fn is_scope_char(b: u8) -> bool { b.is_ascii_graphic() && !matches!(b, b'"' | b'\\') }
178
179#[cfg(test)]
180mod tests {
181 use super::narrow_scope;
182
183 #[test]
184 fn narrow_scope_keeps_known_drops_unknown() {
185 let requested =
186 "openid urn:matrix:client:api:* urn:matrix:client:device:ABCDEFGHIJ custom:x";
187
188 let (granted, device) = narrow_scope(requested, false).expect("narrows");
189
190 assert_eq!(granted, "openid urn:matrix:client:api:* urn:matrix:client:device:ABCDEFGHIJ");
191 assert_eq!(device.as_deref(), Some("ABCDEFGHIJ"));
192 }
193
194 #[test]
195 fn narrow_scope_strict_rejects_unknown() {
196 narrow_scope("openid custom:x", true).unwrap_err();
197 narrow_scope("openid custom:x", false).unwrap();
198 }
199
200 #[test]
201 fn narrow_scope_accepts_unstable_device_spelling() {
202 let scope = "urn:matrix:org.matrix.msc2967.client:device:DEV0123456";
203 let (_granted, device) = narrow_scope(scope, false).expect("narrows");
204
205 assert_eq!(device.as_deref(), Some("DEV0123456"));
206 }
207
208 #[test]
209 fn narrow_scope_rejects_two_device_scopes() {
210 let two = "urn:matrix:client:device:AAAAAAAAAA urn:matrix:client:device:BBBBBBBBBB";
211
212 narrow_scope(two, false).unwrap_err();
213 }
214
215 #[test]
216 fn narrow_scope_accepts_base64_device_id() {
217 let scope = "urn:matrix:client:device:wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/V+afGmU5+0";
218 let (_granted, device) = narrow_scope(scope, false).expect("narrows");
219
220 assert_eq!(device.as_deref(), Some("wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/V+afGmU5+0"));
221 }
222
223 #[test]
224 fn narrow_scope_rejects_invalid_device_id() {
225 narrow_scope("urn:matrix:client:device:bad\"id", false).unwrap_err();
226 narrow_scope("urn:matrix:client:device:bad\\id", false).unwrap_err();
227 }
228}