gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (auth): Silently refresh expired sessions and return users to their page

4523313465d609677aa7e9b8169b86007db72a9d · Michael Hainz · 2026-07-10T10:21:04Z

Changes

8 files changed, +255 -11

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index 789393e..508f4d1 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
28 28 - The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
29 29 - The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
30 30 - Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
31 -- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique across users and organisations). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
31 +- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique across users and organisations). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. The code-flow callback is pinned to `/login` (`redirect-path` + `restore-path-after-redirect`, so strict-`redirect_uri` IdPs like kanidm register one URI) and after login the user lands back on the page they came from — the header's Log in button carries `?redirect=<current page>` (local paths only, open-redirect guarded). Expired ID tokens are refreshed silently with the refresh token (`refresh-expired=true`, 60 s proactive skew, session cookie usable 12 h past expiry) instead of logging the user out. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
32 32 - Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
33 33 - **Collaborators** — the repository owner can grant other local users read+write access
34 34 (one flat role) on a per-repository settings page (`…/settings/collaborators`, reached from a
MODIFY docs/admins/getting-started.md +21 -4
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index aba6f50..563f9d5 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -76,14 +76,17 @@
76 76
77 77 git-shark uses the OIDC **authorization code flow** with **PKCE**. Create a confidential
78 78 client at your IdP and note three things: the **issuer/discovery URL**, the **client ID**,
79 -and the **client secret**. Set the redirect URI to your public origin with a trailing
80 -slash: `https://gitshark.example.com/`.
79 +and the **client secret**. Set the redirect URI to the fixed callback path
80 +`https://gitshark.example.com/login` (git-shark pins the code-flow callback to `/login`
81 +via `quarkus.oidc.authentication.redirect-path`, so IdPs with strict `redirect_uri`
82 +matching — kanidm does this — only need that one URI registered). After the token
83 +exchange git-shark returns the user to the page they were originally on.
81 84
82 85 ### kanidm example
83 86
84 87 ```bash
85 88 kanidm system oauth2 create git-shark "Git Shark" https://gitshark.example.com
86 -kanidm system oauth2 add-redirect-url git-shark https://gitshark.example.com/
89 +kanidm system oauth2 add-redirect-url git-shark https://gitshark.example.com/login
87 90 kanidm group create gitshark_users
88 91 kanidm group add-members gitshark_users <your-user>
89 92 kanidm system oauth2 update-scope-map git-shark gitshark_users openid profile email
@@ -96,12 +99,25 @@
96 99
97 100 Create a confidential client with:
98 101 - Standard flow (authorization code) enabled, PKCE `S256` required.
99 -- Redirect URI `https://gitshark.example.com/`.
102 +- Redirect URI `https://gitshark.example.com/login`.
100 103 - Scopes `openid profile email`.
101 104
102 105 The auth-server URL is the realm issuer, e.g.
103 106 `https://keycloak.example.com/realms/<realm>`.
104 107
108 +### Session lifetime and silent refresh
109 +
110 +The IdP's ID tokens can be short-lived (kanidm issues ~15 min tokens by design); git-shark
111 +does **not** log the user out when one expires. Instead it refreshes the tokens inline with
112 +the refresh token stored in the encrypted session cookie
113 +(`quarkus.oidc.token.refresh-expired=true`, proactively 60 s before expiry) and keeps the
114 +session cookie usable for up to 12 h past ID-token expiry
115 +(`quarkus.oidc.authentication.session-age-extension=PT12H`). This requires the IdP to
116 +actually issue a refresh token to the client — kanidm does for the code flow. kanidm's
117 +refresh-token lifetime is currently hard-coded to 16 h, so that is the ceiling for a fully
118 +silent session; past it the code flow simply runs again (still invisible while the IdP's own
119 +SSO session is alive, otherwise the user logs in once and lands back on the page they were on).
120 +
105 121 ---
106 122
107 123 ## Step 3 — Generate the encryption secrets
@@ -404,6 +420,7 @@
404 420 |---|---|
405 421 | Login redirects to `http://…` or loops | Proxy not forwarding `X-Forwarded-Proto`, or you hit the app over plain HTTP. Front it with TLS (Step 6). |
406 422 | Boot fails on OIDC discovery | `QUARKUS_OIDC_AUTH_SERVER_URL` wrong/unreachable, or IdP demands HTTPS the app can't reach. |
423 +| IdP rejects login with a `redirect_uri` error | The client's registered redirect URI doesn't match the fixed callback `https://<host>/login` (Step 2). Deployments set up before the silent-refresh change registered `https://<host>/` — add/replace it with `/login`. |
407 424 | App exits complaining about secret length | `*_STATE_SECRET` shorter than 32 chars. Regenerate with `openssl rand -hex 16`. |
408 425 | SSH host key changed after redeploy | The `ssh` volume wasn't persisted — confirm it's a named volume, not a throwaway mount. |
409 426 | Profile pictures disappear after redeploy / render as broken images | The `avatars` volume wasn't mounted, so uploads landed in the container layer. Add the volume and `GITSHARK_AVATAR_ROOT` as in Step 5 — retrofit steps in [Persistent data](persistent-data.md#upgrading-a-deployment-created-before-profile-pictures). |
ADD src/main/java/de/workaround/web/CurrentRequest.java +29 -0
diff --git a/src/main/java/de/workaround/web/CurrentRequest.java b/src/main/java/de/workaround/web/CurrentRequest.java
new file mode 100644
index 0000000..3e14c83
--- /dev/null
+++ b/src/main/java/de/workaround/web/CurrentRequest.java
@@ -0,0 +1,29 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URLEncoder;
4 +import java.nio.charset.StandardCharsets;
5 +
6 +import io.vertx.ext.web.RoutingContext;
7 +import jakarta.enterprise.context.RequestScoped;
8 +import jakarta.inject.Inject;
9 +import jakarta.inject.Named;
10 +
11 +/**
12 + * Exposes the current request's location to templates. The header's Log in link points at
13 + * {@code /login?redirect=<current path + query>} so the user returns to the page they were on
14 + * after the OIDC code flow instead of being dumped on the dashboard.
15 + */
16 +@Named("currentRequest")
17 +@RequestScoped
18 +public class CurrentRequest
19 +{
20 + @Inject
21 + RoutingContext routingContext;
22 +
23 + public String loginUrl()
24 + {
25 + // uri() is the server-relative path including the query string
26 + return "/login?redirect=" + URLEncoder.encode(routingContext.request().uri(), StandardCharsets.UTF_8);
27 + }
28 +
29 +}
MODIFY src/main/java/de/workaround/web/HomeResource.java +33 -4
diff --git a/src/main/java/de/workaround/web/HomeResource.java b/src/main/java/de/workaround/web/HomeResource.java
index 831ea94..db8bc97 100644
--- a/src/main/java/de/workaround/web/HomeResource.java
+++ b/src/main/java/de/workaround/web/HomeResource.java
@@ -1,6 +1,7 @@
1 1 package de.workaround.web;
2 2
3 3 import java.net.URI;
4 +import java.net.URISyntaxException;
4 5 import java.util.List;
5 6 import java.util.Set;
6 7 import java.util.UUID;
@@ -28,6 +29,7 @@
28 29 import jakarta.ws.rs.POST;
29 30 import jakarta.ws.rs.Path;
30 31 import jakarta.ws.rs.Produces;
32 +import jakarta.ws.rs.QueryParam;
31 33 import jakarta.ws.rs.core.MediaType;
32 34 import jakarta.ws.rs.core.Response;
33 35
@@ -95,12 +97,39 @@
95 97
96 98 @GET
97 99 @Path("login")
98 - public Response login()
100 + public Response login(@QueryParam("redirect") String redirect)
99 101 {
100 - // Path is protected (authenticated); OIDC intercepts anonymous access and
101 - // redirects here after login, then we send the user to their repository list.
102 + // Path is protected (authenticated); OIDC intercepts anonymous access, runs the code flow
103 + // and restores this URI afterwards (restore-path-after-redirect keeps the redirect
104 + // parameter alive). Send the user back to the page they came from — local paths only,
105 + // anything else would be an open redirect.
102 106 currentUser.require();
103 - return Response.seeOther(URI.create("/")).build();
107 + return Response.seeOther(localRedirectTarget(redirect)).build();
108 + }
109 +
110 + static URI localRedirectTarget(String redirect)
111 + {
112 + // Local paths only: no protocol-relative ("//", "/\") targets and no control characters
113 + // (browsers strip e.g. tabs before parsing, which could re-open the protocol-relative hole).
114 + if (redirect == null || !redirect.startsWith("/")
115 + || redirect.startsWith("//") || redirect.startsWith("/\\")
116 + || redirect.chars().anyMatch(c -> c < 0x20))
117 + {
118 + return URI.create("/");
119 + }
120 + try
121 + {
122 + // Re-encode path and query: the query parameter arrives decoded, and the multi-arg
123 + // URI constructor cannot smuggle in a scheme or authority.
124 + int queryStart = redirect.indexOf('?');
125 + String path = queryStart < 0 ? redirect : redirect.substring(0, queryStart);
126 + String query = queryStart < 0 ? null : redirect.substring(queryStart + 1);
127 + return new URI(null, null, path, query, null);
128 + }
129 + catch (URISyntaxException e)
130 + {
131 + return URI.create("/");
132 + }
104 133 }
105 134
106 135 @GET
MODIFY src/main/resources/application.properties +16 -0
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2266151..3a1142d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -44,6 +44,22 @@
44 44 quarkus.oidc.authentication.force-redirect-https-scheme=true
45 45 %dev,test.quarkus.oidc.authentication.force-redirect-https-scheme=false
46 46
47 +# Session continuity (issue #6): kanidm issues short-lived (~15 min) ID tokens; without
48 +# refresh-expired the session cookie is invalidated on expiry and the user is silently logged out.
49 +# Instead, refresh with the stored refresh token — inline, no redirect, invisible to the user, and
50 +# form POSTs survive. kanidm's refresh token currently lives a hard-coded 16 h (kanidm#3040), so
51 +# keep the session cookie usable for 12 h; refresh proactively 60 s before expiry rather than on
52 +# the first failing request. The refresh token already rides in the encrypted session cookie
53 +# (default keep-all-tokens strategy + token-state-manager.encryption-secret above).
54 +quarkus.oidc.token.refresh-expired=true
55 +quarkus.oidc.token.refresh-token-time-skew=PT60S
56 +quarkus.oidc.authentication.session-age-extension=PT12H
57 +# Fixed code-flow callback (kanidm matches redirect_uri strictly — register <origin>/login there);
58 +# after the token exchange Quarkus restores the originally requested URL, so deep links into
59 +# protected pages survive a re-login, and direct /login?redirect=… visits keep their parameter.
60 +quarkus.oidc.authentication.redirect-path=/login
61 +quarkus.oidc.authentication.restore-path-after-redirect=true
62 +
47 63 # Issue/MR descriptions and review comments are posted as single form fields; the Vert.x default
48 64 # per-attribute cap of 2 KB answered 413 for anything longer than a short paragraph.
49 65 quarkus.http.limits.max-form-attribute-size=256K
MODIFY src/main/resources/templates/layout.html +1 -1
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index bee460f..b235d9a 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -25,7 +25,7 @@
25 25 </div>
26 26 </details>
27 27 {#else}
28 - <a class="btn btn-primary" href="/login">Log in</a>
28 + <a class="btn btn-primary" href="{cdi:currentRequest.loginUrl}">Log in</a>
29 29 {/if}
30 30 {/}
31 31 </nav>
ADD src/test/java/de/workaround/web/LoginRedirectTest.java +153 -0
diff --git a/src/test/java/de/workaround/web/LoginRedirectTest.java b/src/test/java/de/workaround/web/LoginRedirectTest.java
new file mode 100644
index 0000000..20ae73d
--- /dev/null
+++ b/src/test/java/de/workaround/web/LoginRedirectTest.java
@@ -0,0 +1,153 @@
1 +package de.workaround.web;
2 +
3 +import java.time.Duration;
4 +import java.util.UUID;
5 +
6 +import org.eclipse.microprofile.config.Config;
7 +import org.eclipse.microprofile.config.ConfigProvider;
8 +import org.junit.jupiter.api.Test;
9 +
10 +import de.workaround.model.User;
11 +import io.quarkus.test.junit.QuarkusTest;
12 +import io.quarkus.test.security.TestSecurity;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +import static io.restassured.RestAssured.given;
17 +import static org.hamcrest.CoreMatchers.containsString;
18 +import static org.hamcrest.CoreMatchers.not;
19 +import static org.hamcrest.Matchers.endsWith;
20 +import static org.junit.jupiter.api.Assertions.assertEquals;
21 +import static org.junit.jupiter.api.Assertions.assertTrue;
22 +
23 +/**
24 + * Issue #6 parts A and B: after login the user returns to the page they were on, and expired
25 + * sessions are refreshed silently via the OIDC refresh token instead of logging the user out.
26 + */
27 +@QuarkusTest
28 +class LoginRedirectTest
29 +{
30 + // --- Part A: return to the original page after login ---
31 +
32 + @Test
33 + void anonymousLoginLinkCarriesCurrentPageAsRedirect() throws Exception
34 + {
35 + given()
36 + .when().get("/explore")
37 + .then()
38 + .statusCode(200)
39 + .body(containsString("href=\"/login?redirect=%2Fexplore\""));
40 + }
41 +
42 + @Test
43 + @TestSecurity(user = "login-redirect-user")
44 + void loginRedirectsToRequestedLocalPath() throws Exception
45 + {
46 + persistUser("login-redirect-user");
47 +
48 + given()
49 + .queryParam("redirect", "/explore")
50 + .when().redirects().follow(false).get("/login")
51 + .then()
52 + .statusCode(303)
53 + .header("Location", endsWith("/explore"));
54 + }
55 +
56 + @Test
57 + @TestSecurity(user = "login-redirect-user")
58 + void loginRedirectPreservesQueryString() throws Exception
59 + {
60 + persistUser("login-redirect-user");
61 +
62 + given()
63 + .queryParam("redirect", "/explore?tab=all&page=2")
64 + .when().redirects().follow(false).get("/login")
65 + .then()
66 + .statusCode(303)
67 + .header("Location", endsWith("/explore?tab=all&page=2"));
68 + }
69 +
70 + @Test
71 + @TestSecurity(user = "login-redirect-user")
72 + void loginWithoutRedirectFallsBackToDashboard() throws Exception
73 + {
74 + persistUser("login-redirect-user");
75 +
76 + given()
77 + .when().redirects().follow(false).get("/login")
78 + .then()
79 + .statusCode(303)
80 + .header("Location", endsWith("/"));
81 + }
82 +
83 + @Test
84 + @TestSecurity(user = "login-redirect-user")
85 + void loginRejectsAbsoluteAndProtocolRelativeRedirects() throws Exception
86 + {
87 + persistUser("login-redirect-user");
88 +
89 + // "/\t//evil…" covers the control-character class: browsers strip the tab before parsing,
90 + // which would turn a naive prefix-checked path into a protocol-relative URL
91 + for (String evil : new String[] { "https://evil.example/phish", "//evil.example/phish",
92 + "/\\evil.example/phish", "javascript:alert(1)", "/\t//evil.example/phish" })
93 + {
94 + given()
95 + .queryParam("redirect", evil)
96 + .when().redirects().follow(false).get("/login")
97 + .then()
98 + .statusCode(303)
99 + .header("Location", not(containsString("evil")))
100 + .header("Location", endsWith("/"));
101 + }
102 + }
103 +
104 + // --- Part B: silent session refresh via the OIDC refresh token ---
105 +
106 + @Test
107 + void silentSessionRefreshIsConfigured() throws Exception
108 + {
109 + Config config = ConfigProvider.getConfig();
110 + assertTrue(config.getValue("quarkus.oidc.token.refresh-expired", Boolean.class),
111 + "expired ID tokens must be refreshed silently instead of dropping the session");
112 + assertEquals(Duration.ofHours(12),
113 + config.getValue("quarkus.oidc.authentication.session-age-extension", Duration.class),
114 + "session cookie must outlive the short-lived ID token so the refresh can run");
115 + assertEquals(Duration.ofSeconds(60),
116 + config.getValue("quarkus.oidc.token.refresh-token-time-skew", Duration.class),
117 + "tokens must be refreshed proactively shortly before expiry");
118 + }
119 +
120 + @Test
121 + void codeFlowCallbackRestoresOriginalPage() throws Exception
122 + {
123 + Config config = ConfigProvider.getConfig();
124 + assertEquals("/login", config.getValue("quarkus.oidc.authentication.redirect-path", String.class),
125 + "fixed callback path so kanidm's strict redirect_uri matching accepts one registered URI");
126 + assertTrue(config.getValue("quarkus.oidc.authentication.restore-path-after-redirect", Boolean.class),
127 + "after the code flow the user must land on the originally requested URL");
128 + }
129 +
130 + private static String unique()
131 + {
132 + return UUID.randomUUID().toString().substring(0, 8);
133 + }
134 +
135 + @Transactional
136 + User persistUser(String name)
137 + {
138 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
139 + if (existing != null)
140 + {
141 + return existing;
142 + }
143 + User user = new User();
144 + user.oidcSub = name;
145 + user.username = name;
146 + user.persist();
147 + return user;
148 + }
149 +
150 + @Inject
151 + User.Repo userRepo;
152 +
153 +}
MODIFY src/test/java/de/workaround/web/WebUiTest.java +1 -1
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index 3d2fff8..bad1617 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -534,7 +534,7 @@
534 534 .when().get("/explore")
535 535 .then()
536 536 .statusCode(200)
537 - .body(containsString("href=\"/login\""))
537 + .body(containsString("href=\"/login?redirect="))
538 538 .body(not(containsString("Logout")))
539 539 .body(not(containsString("Access tokens")))
540 540 .body(not(containsString("/settings/profile")));

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog