Skip to content

Commit 7500bfd

Browse files
committed
feat(examples): add keycloak-forgejo VM example
Boots both pairings together with a custom keycloak login theme, SSO from forgejo into keycloak, a private internal repo, and per-user avatars. Inert until wired into the flake (next commit).
1 parent df70e5f commit 7500bfd

6 files changed

Lines changed: 452 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Example: keycloak + forgejo (Dunder Mifflin Paper Co.)
2+
3+
A disposable NixOS VM that exercises both pairings together:
4+
`services.keycloak.runtime.*` and `services.forgejo.runtime.*` plus
5+
the SSO loop wiring Keycloak as a Forgejo OAuth2 login source.
6+
7+
## Run it
8+
9+
From the repository root:
10+
11+
```sh
12+
nix run .#keycloak-forgejo
13+
```
14+
15+
Boot takes ~3-4 min. The console auto-logs in as `root` / `hackme`.
16+
A `scranton.qcow2` lands in CWD; delete it to start over. Exit with
17+
`Ctrl+a x` or `poweroff` from the guest.
18+
19+
## Forwarded ports
20+
21+
| Host port | Guest | What |
22+
| --------- | ----- | ----------------------------------------------------- |
23+
| 2222 | 22 | SSH (`ssh -p 2222 root@localhost`, password `hackme`) |
24+
| 8080 | 8080 | Keycloak |
25+
| 3000 | 3000 | Forgejo |
26+
| 8888 | 8888 | Static avatar host (`jhalpert.png`) |
27+
28+
## What it demonstrates
29+
30+
**Keycloak (`services.keycloak.runtime`):**
31+
32+
- Realm `dunder_mifflin` with a custom `dunder_mifflin` login theme
33+
(Scranton blue on corporate beige -- see `themes/dunder_mifflin/`).
34+
- Declarative user-profile schema (`realm_user_profiles`) so the
35+
`picture` attribute survives Keycloak v24+'s default
36+
`unmanaged_attribute_policy = DISABLED`.
37+
- User `jhalpert` (Jim Halpert) with `initial_password.valueFile`
38+
indirection and a `picture` attribute pointing at the avatar host.
39+
- OIDC client `dunder-mifflin-infinity` with `client_secretFile`
40+
indirection and a redirect URI pointed at Forgejo's OAuth2 callback.
41+
42+
**Forgejo (`services.forgejo.runtime`):**
43+
44+
- Org `dunder_mifflin`, public repo `scranton_branch`, private repo
45+
`intranet`.
46+
- Pre-created users `jhalpert` and `dschrute` (passwords via
47+
`passwordFile`).
48+
- Collaborator binding granting `jhalpert` write on `intranet`.
49+
50+
**Glue (plain systemd one-shots, not runtime-state):**
51+
52+
- `nginx` serving the rasterised PNG avatar at
53+
`http://localhost:8888/jhalpert.png`.
54+
- `forgejo-oauth-setup` calling `forgejo admin auth add-oauth` to
55+
register Keycloak as a Forgejo login source named
56+
`DunderMifflinInfinity` (the svalabs/forgejo terraform provider
57+
doesn't model auth sources, so the runtime layer can't reach it).
58+
59+
## Try it
60+
61+
1. **Forgejo (local user).** <http://localhost:3000> -> login
62+
`dschrute` / `hackme`. See the org and the public `scranton_branch`
63+
repo; the private `intranet` repo is not visible.
64+
2. **Keycloak account console.**
65+
<http://localhost:8080/realms/dunder_mifflin/account/> -> login
66+
`jhalpert` / `hackme`. The themed login page (beige + Scranton
67+
blue) and Jim's pre-populated profile.
68+
3. **SSO loop.** Sign out of Forgejo. Visit
69+
<http://localhost:3000/user/login> and click **Sign in with
70+
DunderMifflinInfinity** (below the local login form) -> log in
71+
as `jhalpert` / `hackme` on the themed Keycloak page -> Forgejo
72+
links the SSO identity to the pre-created `jhalpert` (matched by
73+
email), pulls his avatar via the OIDC `picture` claim, and lands
74+
you on his dashboard.
75+
4. **Internal repo.** As Jim, navigate to
76+
<http://localhost:3000/dunder_mifflin/intranet>. He has write
77+
access. Sign out (or sign in as `dschrute`), the URL returns 404.
78+
79+
## Known wrinkles
80+
81+
- **First-boot avatar.** Tofu creates `keycloak_user` and
82+
`keycloak_realm_user_profile` in parallel; the user-create request
83+
can race the schema, so on first apply Keycloak drops the `picture`
84+
attribute silently. Restarting `declarative-keycloak.service` (or
85+
rebooting) refreshes drift and re-PUTs the attribute. The fix is to
86+
emit a `depends_on` edge from user resources to their realm's
87+
user-profile -- a renderer change worth landing soon.
Lines changed: 7 additions & 0 deletions
Loading
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
{
2+
config,
3+
pkgs,
4+
modulesPath,
5+
...
6+
}:
7+
{
8+
imports = [ "${modulesPath}/virtualisation/qemu-vm.nix" ];
9+
10+
networking.hostName = "scranton";
11+
networking.firewall.enable = false;
12+
time.timeZone = "America/New_York";
13+
14+
services.openssh.enable = true;
15+
services.openssh.settings.PermitRootLogin = "yes";
16+
users.users.root.password = "hackme";
17+
services.getty.autologinUser = "root";
18+
19+
environment.systemPackages = with pkgs; [
20+
curl
21+
jq
22+
];
23+
24+
# demo-only: stand-ins for the host-file secrets that <attr>File options
25+
# reach for via systemd LoadCredential. real deployments source these
26+
# from sops-nix / agenix; never the world-readable nix store.
27+
environment.etc = {
28+
"secrets/keycloak-db-password".text = "hackme";
29+
"secrets/keycloak-admin-password".text = "hackme";
30+
"secrets/jhalpert-password".text = "hackme";
31+
"secrets/dunder-mifflin-app-client-secret".text = "topsecret";
32+
"secrets/dschrute-password".text = "hackme";
33+
"secrets/jhalpert-forgejo-password".text = "hackme";
34+
};
35+
36+
virtualisation = {
37+
memorySize = 4096;
38+
diskSize = 8192;
39+
graphics = false;
40+
forwardPorts = [
41+
{
42+
from = "host";
43+
host.port = 2222;
44+
guest.port = 22;
45+
}
46+
{
47+
from = "host";
48+
host.port = 8080;
49+
guest.port = 8080;
50+
}
51+
{
52+
from = "host";
53+
host.port = 3000;
54+
guest.port = 3000;
55+
}
56+
{
57+
from = "host";
58+
host.port = 8888;
59+
guest.port = 8888;
60+
}
61+
];
62+
};
63+
64+
# static avatar host. svg rasterised to png at build time so forgejo's
65+
# Go image decoder (no svg support) can ingest it on SSO. virtualHost
66+
# name doubles as server_name; "localhost" matches the Host header from
67+
# both the host browser and forgejo's avatar fetcher inside the vm.
68+
services.nginx = {
69+
enable = true;
70+
virtualHosts.localhost = {
71+
default = true;
72+
listen = [
73+
{
74+
addr = "0.0.0.0";
75+
port = 8888;
76+
}
77+
];
78+
root = pkgs.runCommand "avatars" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
79+
mkdir -p $out
80+
rsvg-convert -w 200 -h 200 -o $out/jhalpert.png ${./avatars/jhalpert.svg}
81+
'';
82+
};
83+
};
84+
85+
services.keycloak = {
86+
enable = true;
87+
initialAdminPassword = "hackme";
88+
settings = {
89+
hostname = "localhost";
90+
http-port = 8080;
91+
http-enabled = true;
92+
hostname-strict = false;
93+
};
94+
database.passwordFile = "/etc/secrets/keycloak-db-password";
95+
96+
themes.dunder_mifflin = pkgs.runCommand "keycloak-theme-dunder-mifflin" { } ''
97+
cp -r ${./themes/dunder_mifflin} $out
98+
'';
99+
100+
runtime = {
101+
enable = true;
102+
bootstrapAdminPasswordFile = "/etc/secrets/keycloak-admin-password";
103+
104+
realms.dunder_mifflin = {
105+
display_name = "Dunder Mifflin Paper Company";
106+
login_theme = "dunder_mifflin";
107+
};
108+
109+
# declare picture (and the four standard attrs) and flip unmanaged
110+
# to ENABLED so keycloak v24+'s declarative profile stores
111+
# users.jhalpert.attributes.picture instead of silently dropping it.
112+
realm_user_profiles.dunder_mifflin =
113+
let
114+
stdPerms = {
115+
view = [
116+
"admin"
117+
"user"
118+
];
119+
edit = [
120+
"admin"
121+
"user"
122+
];
123+
};
124+
stdAttr = name: {
125+
inherit name;
126+
permissions = stdPerms;
127+
};
128+
in
129+
{
130+
realm = "dunder_mifflin";
131+
unmanaged_attribute_policy = "ENABLED";
132+
attribute = [
133+
(stdAttr "username")
134+
(stdAttr "email")
135+
(stdAttr "firstName")
136+
(stdAttr "lastName")
137+
{
138+
name = "picture";
139+
display_name = "Avatar URL";
140+
permissions = stdPerms;
141+
}
142+
];
143+
};
144+
145+
users.jhalpert = {
146+
realm = "dunder_mifflin";
147+
username = "jhalpert";
148+
email = "jim@dundermifflin.com";
149+
first_name = "Jim";
150+
last_name = "Halpert";
151+
enabled = true;
152+
email_verified = true;
153+
# default `profile` client scope maps this into the OIDC `picture`
154+
# claim; forgejo pulls it on SSO when UPDATE_AVATAR=true.
155+
attributes.picture = "http://localhost:8888/jhalpert.png";
156+
initial_password = {
157+
valueFile = "/etc/secrets/jhalpert-password";
158+
temporary = false;
159+
};
160+
};
161+
162+
openid_clients.dunder_mifflin_infinity = {
163+
realm = "dunder_mifflin";
164+
client_id = "dunder-mifflin-infinity";
165+
name = "Dunder Mifflin Infinity";
166+
access_type = "CONFIDENTIAL";
167+
client_secretFile = "/etc/secrets/dunder-mifflin-app-client-secret";
168+
valid_redirect_uris = [ "http://localhost:3000/user/oauth2/DunderMifflinInfinity/callback" ];
169+
web_origins = [ "http://localhost:3000" ];
170+
standard_flow_enabled = true;
171+
};
172+
};
173+
};
174+
175+
services.forgejo = {
176+
enable = true;
177+
settings.server = {
178+
HTTP_PORT = 3000;
179+
DOMAIN = "localhost";
180+
ROOT_URL = "http://localhost:3000/";
181+
};
182+
settings.security.MIN_PASSWORD_LENGTH = 6;
183+
# forgejo's HTTP client blocks RFC1918 + loopback by default (anti-SSRF);
184+
# avatar pulls go through that same client, so the local nginx is
185+
# unreachable without this flip.
186+
settings.migrations.ALLOW_LOCALNETWORKS = true;
187+
settings.oauth2_client = {
188+
ENABLE_AUTO_REGISTRATION = true;
189+
USERNAME = "preferred_username";
190+
ACCOUNT_LINKING = "auto";
191+
UPDATE_AVATAR = true;
192+
};
193+
194+
runtime = {
195+
enable = true;
196+
197+
organizations.dunder_mifflin = {
198+
visibility = "public";
199+
description = "Dunder Mifflin Paper Company, Inc.";
200+
};
201+
202+
repositories.scranton_branch = {
203+
owner = "dunder_mifflin";
204+
description = "The best branch in the company";
205+
private = false;
206+
};
207+
208+
# internal repo: only collaborators can see it. forgejo's SSO
209+
# auto-registration creates a user with login = preferred_username
210+
# (`jhalpert`); pre-creating jhalpert here lets us name him as a
211+
# collaborator. ACCOUNT_LINKING=auto matches by email on first SSO,
212+
# so the pre-created + SSO accounts are the same forgejo user.
213+
repositories.intranet = {
214+
owner = "dunder_mifflin";
215+
description = "Internal Dunder Mifflin intranet -- not for the warehouse";
216+
private = true;
217+
};
218+
219+
users.jhalpert = {
220+
email = "jim@dundermifflin.com";
221+
full_name = "Jim Halpert";
222+
passwordFile = "/etc/secrets/jhalpert-forgejo-password";
223+
must_change_password = false;
224+
};
225+
226+
users.dschrute = {
227+
email = "dschrute@dundermifflin.com";
228+
passwordFile = "/etc/secrets/dschrute-password";
229+
must_change_password = false;
230+
};
231+
232+
collaborators.jhalpert_intranet = {
233+
repository = "intranet";
234+
user = "jhalpert";
235+
permission = "write";
236+
};
237+
};
238+
};
239+
240+
# SSO glue: the svalabs/forgejo terraform provider doesn't model auth
241+
# sources, so this oneshot calls `forgejo admin auth add-oauth` after
242+
# both reconcilers are done. The source name doubles as the URL slug
243+
# in the OAuth2 callback path (must match keycloak's valid_redirect_uris).
244+
systemd.services.forgejo-oauth-setup =
245+
let
246+
fcfg = config.services.forgejo;
247+
appIni = "${fcfg.customDir}/conf/app.ini";
248+
in
249+
{
250+
description = "Register Keycloak as a forgejo OAuth2 login source";
251+
after = [
252+
"forgejo.service"
253+
"declarative-keycloak.service"
254+
];
255+
requires = [
256+
"forgejo.service"
257+
"declarative-keycloak.service"
258+
];
259+
wantedBy = [ "multi-user.target" ];
260+
path = [
261+
fcfg.package
262+
pkgs.gawk
263+
pkgs.curl
264+
];
265+
environment = {
266+
GITEA_WORK_DIR = fcfg.stateDir;
267+
GITEA_CUSTOM = fcfg.customDir;
268+
};
269+
serviceConfig = {
270+
Type = "oneshot";
271+
RemainAfterExit = true;
272+
User = fcfg.user;
273+
Group = fcfg.group;
274+
LoadCredential = [ "client-secret:/etc/secrets/dunder-mifflin-app-client-secret" ];
275+
};
276+
script = ''
277+
set -euo pipefail
278+
sso_name=DunderMifflinInfinity
279+
if forgejo --config '${appIni}' admin auth list | awk 'NR>1 {print $2}' | grep -qx "$sso_name"; then
280+
exit 0
281+
fi
282+
for _ in $(seq 1 60); do
283+
if curl -fsS -o /dev/null \
284+
http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration; then
285+
break
286+
fi
287+
sleep 2
288+
done
289+
secret="$(cat "$CREDENTIALS_DIRECTORY/client-secret")"
290+
forgejo --config '${appIni}' admin auth add-oauth \
291+
--name "$sso_name" \
292+
--provider openidConnect \
293+
--key dunder-mifflin-infinity \
294+
--secret "$secret" \
295+
--scopes "openid email profile" \
296+
--auto-discover-url http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration
297+
'';
298+
};
299+
300+
system.stateVersion = "26.05";
301+
}

0 commit comments

Comments
 (0)