1# NixOS module for oauth2_proxy.
2
3{ config, lib, pkgs, ... }:
4
5with lib;
6let
7 cfg = config.services.oauth2_proxy;
8
9 # Use like:
10 # repeatedArgs (arg: "--arg=${arg}") args
11 repeatedArgs = concatMapStringsSep " ";
12
13 # 'toString' doesn't quite do what we want for bools.
14 fromBool = x: if x then "true" else "false";
15
16 # oauth2_proxy provides many options that are only relevant if you are using
17 # a certain provider. This set maps from provider name to a function that
18 # takes the configuration and returns a string that can be inserted into the
19 # command-line to launch oauth2_proxy.
20 providerSpecificOptions = {
21 azure = cfg: ''
22 --azure-tenant=${cfg.azure.tenant} \
23 --resource=${cfg.azure.resource} \
24 '';
25
26 github = cfg: ''
27 $(optionalString (!isNull cfg.github.org) "--github-org=${cfg.github.org}") \
28 $(optionalString (!isNull cfg.github.team) "--github-org=${cfg.github.team}") \
29 '';
30
31 google = cfg: ''
32 --google-admin-email=${cfg.google.adminEmail} \
33 --google-service-account=${cfg.google.serviceAccountJSON} \
34 $(repeatedArgs (group: "--google-group=${group}") cfg.google.groups) \
35 '';
36 };
37
38 authenticatedEmailsFile = pkgs.writeText "authenticated-emails" cfg.email.addresses;
39
40 getProviderOptions = cfg: provider:
41 if providerSpecificOptions ? provider then providerSpecificOptions.provider cfg else "";
42
43 mkCommandLine = cfg: ''
44 --provider='${cfg.provider}' \
45 ${optionalString (!isNull cfg.email.addresses) "--authenticated-emails-file='${authenticatedEmailsFile}'"} \
46 --approval-prompt='${cfg.approvalPrompt}' \
47 ${optionalString (cfg.passBasicAuth && !isNull cfg.basicAuthPassword) "--basic-auth-password='${cfg.basicAuthPassword}'"} \
48 --client-id='${cfg.clientID}' \
49 --client-secret='${cfg.clientSecret}' \
50 ${optionalString (!isNull cfg.cookie.domain) "--cookie-domain='${cfg.cookie.domain}'"} \
51 --cookie-expire='${cfg.cookie.expire}' \
52 --cookie-httponly=${fromBool cfg.cookie.httpOnly} \
53 --cookie-name='${cfg.cookie.name}' \
54 --cookie-secret='${cfg.cookie.secret}' \
55 --cookie-secure=${fromBool cfg.cookie.secure} \
56 ${optionalString (!isNull cfg.cookie.refresh) "--cookie-refresh='${cfg.cookie.refresh}'"} \
57 ${optionalString (!isNull cfg.customTemplatesDir) "--custom-templates-dir='${cfg.customTemplatesDir}'"} \
58 ${repeatedArgs (x: "--email-domain='${x}'") cfg.email.domains} \
59 --http-address='${cfg.httpAddress}' \
60 ${optionalString (!isNull cfg.htpasswd.file) "--htpasswd-file='${cfg.htpasswd.file}' --display-htpasswd-form=${fromBool cfg.htpasswd.displayForm}"} \
61 ${optionalString (!isNull cfg.loginURL) "--login-url='${cfg.loginURL}'"} \
62 --pass-access-token=${fromBool cfg.passAccessToken} \
63 --pass-basic-auth=${fromBool cfg.passBasicAuth} \
64 --pass-host-header=${fromBool cfg.passHostHeader} \
65 --proxy-prefix='${cfg.proxyPrefix}' \
66 ${optionalString (!isNull cfg.profileURL) "--profile-url='${cfg.profileURL}'"} \
67 ${optionalString (!isNull cfg.redeemURL) "--redeem-url='${cfg.redeemURL}'"} \
68 ${optionalString (!isNull cfg.redirectURL) "--redirect-url='${cfg.redirectURL}'"} \
69 --request-logging=${fromBool cfg.requestLogging} \
70 ${optionalString (!isNull cfg.scope) "--scope='${cfg.scope}'"} \
71 ${repeatedArgs (x: "--skip-auth-regex='${x}'") cfg.skipAuthRegexes} \
72 ${optionalString (!isNull cfg.signatureKey) "--signature-key='${cfg.signatureKey}'"} \
73 --upstream='${cfg.upstream}' \
74 ${optionalString (!isNull cfg.validateURL) "--validate-url='${cfg.validateURL}'"} \
75 ${optionalString cfg.tls.enable "--tls-cert='${cfg.tls.certificate}' --tls-key='${cfg.tls.key}' --https-address='${cfg.tls.httpsAddress}'"} \
76 '' + getProviderOptions cfg cfg.provider;
77in
78{
79 options.services.oauth2_proxy = {
80 enable = mkEnableOption "oauth2_proxy";
81
82 package = mkOption {
83 type = types.package;
84 default = pkgs.oauth2_proxy;
85 defaultText = "pkgs.oauth2_proxy";
86 description = ''
87 The package that provides oauth2_proxy.
88 '';
89 };
90
91 ##############################################
92 # PROVIDER configuration
93 provider = mkOption {
94 type = types.enum [
95 "google"
96 "github"
97 "azure"
98 "gitlab"
99 "linkedin"
100 "myusa"
101 ];
102 default = "google";
103 description = ''
104 OAuth provider.
105 '';
106 };
107
108 approvalPrompt = mkOption {
109 type = types.enum ["force" "auto"];
110 default = "force";
111 description = ''
112 OAuth approval_prompt.
113 '';
114 };
115
116 clientID = mkOption {
117 type = types.str;
118 description = ''
119 The OAuth Client ID.
120 '';
121 example = "123456.apps.googleusercontent.com";
122 };
123
124 clientSecret = mkOption {
125 type = types.str;
126 description = ''
127 The OAuth Client Secret.
128 '';
129 };
130
131 skipAuthRegexes = mkOption {
132 type = types.listOf types.str;
133 default = [];
134 description = ''
135 Skip authentication for requests matching any of these regular
136 expressions.
137 '';
138 };
139
140 # XXX: Not clear whether these two options are mutually exclusive or not.
141 email = {
142 domains = mkOption {
143 type = types.listOf types.str;
144 default = [];
145 description = ''
146 Authenticate emails with the specified domains. Use
147 <literal>*</literal> to authenticate any email.
148 '';
149 };
150
151 addresses = mkOption {
152 type = types.nullOr types.lines;
153 default = null;
154 description = ''
155 Line-separated email addresses that are allowed to authenticate.
156 '';
157 };
158 };
159
160 loginURL = mkOption {
161 type = types.nullOr types.str;
162 default = null;
163 description = ''
164 Authentication endpoint.
165
166 You only need to set this if you are using a self-hosted provider (e.g.
167 Github Enterprise). If you're using a publicly hosted provider
168 (e.g github.com), then the default works.
169 '';
170 example = "https://provider.example.com/oauth/authorize";
171 };
172
173 redeemURL = mkOption {
174 type = types.nullOr types.str;
175 default = null;
176 description = ''
177 Token redemption endpoint.
178
179 You only need to set this if you are using a self-hosted provider (e.g.
180 Github Enterprise). If you're using a publicly hosted provider
181 (e.g github.com), then the default works.
182 '';
183 example = "https://provider.example.com/oauth/token";
184 };
185
186 validateURL = mkOption {
187 type = types.nullOr types.str;
188 default = null;
189 description = ''
190 Access token validation endpoint.
191
192 You only need to set this if you are using a self-hosted provider (e.g.
193 Github Enterprise). If you're using a publicly hosted provider
194 (e.g github.com), then the default works.
195 '';
196 example = "https://provider.example.com/user/emails";
197 };
198
199 redirectURL = mkOption {
200 # XXX: jml suspects this is always necessary, but the command-line
201 # doesn't require it so making it optional.
202 type = types.nullOr types.str;
203 default = null;
204 description = ''
205 The OAuth2 redirect URL.
206 '';
207 example = "https://internalapp.yourcompany.com/oauth2/callback";
208 };
209
210 azure = {
211 tenant = mkOption {
212 type = types.str;
213 default = "common";
214 description = ''
215 Go to a tenant-specific or common (tenant-independent) endpoint.
216 '';
217 };
218
219 resource = mkOption {
220 type = types.str;
221 description = ''
222 The resource that is protected.
223 '';
224 };
225 };
226
227 google = {
228 adminEmail = mkOption {
229 type = types.str;
230 description = ''
231 The Google Admin to impersonate for API calls.
232
233 Only users with access to the Admin APIs can access the Admin SDK
234 Directory API, thus the service account needs to impersonate one of
235 those users to access the Admin SDK Directory API.
236
237 See <link xlink:href="https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account" />.
238 '';
239 };
240
241 groups = mkOption {
242 type = types.listOf types.str;
243 default = [];
244 description = ''
245 Restrict logins to members of these Google groups.
246 '';
247 };
248
249 serviceAccountJSON = mkOption {
250 type = types.path;
251 description = ''
252 The path to the service account JSON credentials.
253 '';
254 };
255 };
256
257 github = {
258 org = mkOption {
259 type = types.nullOr types.str;
260 default = null;
261 description = ''
262 Restrict logins to members of this organisation.
263 '';
264 };
265
266 team = mkOption {
267 type = types.nullOr types.str;
268 default = null;
269 description = ''
270 Restrict logins to members of this team.
271 '';
272 };
273 };
274
275
276 ####################################################
277 # UPSTREAM Configuration
278 upstream = mkOption {
279 type = types.commas;
280 description = ''
281 The http url(s) of the upstream endpoint or <literal>file://</literal>
282 paths for static files. Routing is based on the path.
283 '';
284 };
285
286 passAccessToken = mkOption {
287 type = types.bool;
288 default = false;
289 description = ''
290 Pass OAuth access_token to upstream via X-Forwarded-Access-Token header.
291 '';
292 };
293
294 passBasicAuth = mkOption {
295 type = types.bool;
296 default = true;
297 description = ''
298 Pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream.
299 '';
300 };
301
302 basicAuthPassword = mkOption {
303 type = types.nullOr types.str;
304 default = null;
305 description = ''
306 The password to set when passing the HTTP Basic Auth header.
307 '';
308 };
309
310 passHostHeader = mkOption {
311 type = types.bool;
312 default = true;
313 description = ''
314 Pass the request Host Header to upstream.
315 '';
316 };
317
318 signatureKey = mkOption {
319 type = types.nullOr types.str;
320 default = null;
321 description = ''
322 GAP-Signature request signature key.
323 '';
324 example = "sha1:secret0";
325 };
326
327 cookie = {
328 domain = mkOption {
329 type = types.nullOr types.str;
330 default = null;
331 description = ''
332 An optional cookie domain to force cookies to.
333 '';
334 example = ".yourcompany.com";
335 };
336
337 expire = mkOption {
338 type = types.str;
339 default = "168h0m0s";
340 description = ''
341 Expire timeframe for cookie.
342 '';
343 };
344
345 httpOnly = mkOption {
346 type = types.bool;
347 default = true;
348 description = ''
349 Set HttpOnly cookie flag.
350 '';
351 };
352
353 name = mkOption {
354 type = types.str;
355 default = "_oauth2_proxy";
356 description = ''
357 The name of the cookie that the oauth_proxy creates.
358 '';
359 };
360
361 refresh = mkOption {
362 # XXX: Unclear what the behavior is when this is not specified.
363 type = types.nullOr types.str;
364 default = null;
365 description = ''
366 Refresh the cookie after this duration; 0 to disable.
367 '';
368 example = "168h0m0s";
369 };
370
371 secret = mkOption {
372 type = types.str;
373 description = ''
374 The seed string for secure cookies.
375 '';
376 };
377
378 secure = mkOption {
379 type = types.bool;
380 default = true;
381 description = ''
382 Set secure (HTTPS) cookie flag.
383 '';
384 };
385 };
386
387 ####################################################
388 # OAUTH2 PROXY configuration
389
390 httpAddress = mkOption {
391 type = types.str;
392 default = "127.0.0.1:4180";
393 description = ''
394 HTTPS listening address. This module does not expose the port by
395 default. If you want this URL to be accessible to other machines, please
396 add the port to <literal>networking.firewall.allowedTCPPorts</literal>.
397 '';
398 };
399
400 htpasswd = {
401 file = mkOption {
402 type = types.nullOr types.path;
403 default = null;
404 description = ''
405 Additionally authenticate against a htpasswd file. Entries must be
406 created with <literal>htpasswd -s</literal> for SHA encryption.
407 '';
408 };
409
410 displayForm = mkOption {
411 type = types.bool;
412 default = true;
413 description = ''
414 Display username / password login form if an htpasswd file is provided.
415 '';
416 };
417 };
418
419 customTemplatesDir = mkOption {
420 type = types.nullOr types.path;
421 default = null;
422 description = ''
423 Path to custom HTML templates.
424 '';
425 };
426
427 proxyPrefix = mkOption {
428 type = types.str;
429 default = "/oauth2";
430 description = ''
431 The url root path that this proxy should be nested under.
432 '';
433 };
434
435 tls = {
436 enable = mkOption {
437 type = types.bool;
438 default = false;
439 description = ''
440 Whether to serve over TLS.
441 '';
442 };
443
444 certificate = mkOption {
445 type = types.path;
446 description = ''
447 Path to certificate file.
448 '';
449 };
450
451 key = mkOption {
452 type = types.path;
453 description = ''
454 Path to private key file.
455 '';
456 };
457
458 httpsAddress = mkOption {
459 type = types.str;
460 default = ":443";
461 description = ''
462 <literal>addr:port</literal> to listen on for HTTPS clients.
463
464 Remember to add <literal>port</literal> to
465 <literal>allowedTCPPorts</literal> if you want other machines to be
466 able to connect to it.
467 '';
468 };
469 };
470
471 requestLogging = mkOption {
472 type = types.bool;
473 default = true;
474 description = ''
475 Log requests to stdout.
476 '';
477 };
478
479 ####################################################
480 # UNKNOWN
481
482 # XXX: Is this mandatory? Is it part of another group? Is it part of the provider specification?
483 scope = mkOption {
484 # XXX: jml suspects this is always necessary, but the command-line
485 # doesn't require it so making it optional.
486 type = types.nullOr types.str;
487 default = null;
488 description = ''
489 OAuth scope specification.
490 '';
491 };
492
493 profileURL = mkOption {
494 type = types.nullOr types.str;
495 default = null;
496 description = ''
497 Profile access endpoint.
498 '';
499 };
500
501 };
502
503 config = mkIf cfg.enable {
504
505 users.extraUsers.oauth2_proxy = {
506 description = "OAuth2 Proxy";
507 };
508
509 systemd.services.oauth2_proxy = {
510 description = "OAuth2 Proxy";
511 path = [ cfg.package ];
512 wantedBy = [ "multi-user.target" ];
513 after = [ "network-interfaces.target" ];
514
515 serviceConfig = {
516 User = "oauth2_proxy";
517 Restart = "always";
518 ExecStart = "${cfg.package.bin}/bin/oauth2_proxy ${mkCommandLine cfg}";
519 };
520 };
521
522 };
523}