✨ (auth): Silently refresh expired sessions and return users to their page
Changes
8 files changed, +255 -11
MODIFY
README.md
+1 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")));