Skip to content

Commit 426eac2

Browse files
authored
refactor: rework oidc session storage (#913)
1 parent da17be4 commit 426eac2

26 files changed

Lines changed: 786 additions & 2339 deletions

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ TINYAUTH_APPURL=
77

88
# database config
99

10-
# The database driver to use. Valid values: sqlite, memory.
10+
# The database driver to use. Valid values: sqlite, postgres, memory.
1111
TINYAUTH_DATABASE_DRIVER="sqlite"
12-
# The path to the SQLite database, including file name. Only used when driver is sqlite.
12+
# The path to the SQLite database file, or connection URL when driver is postgres.
1313
TINYAUTH_DATABASE_PATH="./tinyauth.db"
1414

1515
# analytics config
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
DROP TABLE IF EXISTS "oidc_sessions";
2+
3+
CREATE TABLE "oidc_codes" (
4+
"sub" TEXT NOT NULL UNIQUE,
5+
"code_hash" TEXT NOT NULL PRIMARY KEY,
6+
"scope" TEXT NOT NULL,
7+
"redirect_uri" TEXT NOT NULL,
8+
"client_id" TEXT NOT NULL,
9+
"expires_at" BIGINT NOT NULL,
10+
"nonce" TEXT NOT NULL DEFAULT '',
11+
"code_challenge" TEXT NOT NULL DEFAULT ''
12+
);
13+
14+
CREATE TABLE "oidc_tokens" (
15+
"sub" TEXT NOT NULL UNIQUE,
16+
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
17+
"refresh_token_hash" TEXT NOT NULL,
18+
"code_hash" TEXT NOT NULL,
19+
"scope" TEXT NOT NULL,
20+
"client_id" TEXT NOT NULL,
21+
"token_expires_at" BIGINT NOT NULL,
22+
"refresh_token_expires_at" BIGINT NOT NULL,
23+
"nonce" TEXT NOT NULL DEFAULT ''
24+
);
25+
26+
CREATE TABLE "oidc_userinfo" (
27+
"sub" TEXT NOT NULL PRIMARY KEY,
28+
"name" TEXT NOT NULL,
29+
"preferred_username" TEXT NOT NULL,
30+
"email" TEXT NOT NULL,
31+
"groups" TEXT NOT NULL,
32+
"updated_at" BIGINT NOT NULL,
33+
"given_name" TEXT NOT NULL,
34+
"family_name" TEXT NOT NULL,
35+
"middle_name" TEXT NOT NULL,
36+
"nickname" TEXT NOT NULL,
37+
"profile" TEXT NOT NULL,
38+
"picture" TEXT NOT NULL,
39+
"website" TEXT NOT NULL,
40+
"gender" TEXT NOT NULL,
41+
"birthdate" TEXT NOT NULL,
42+
"zoneinfo" TEXT NOT NULL,
43+
"locale" TEXT NOT NULL,
44+
"phone_number" TEXT NOT NULL,
45+
"address" TEXT NOT NULL
46+
);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
This migration will nuke the entire setup of OIDC sessions and merge everything
3+
into one table.
4+
*/
5+
6+
/*
7+
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
8+
*/
9+
10+
DROP TABLE IF EXISTS "oidc_tokens";
11+
DROP TABLE IF EXISTS "oidc_userinfo";
12+
DROP TABLE IF EXISTS "oidc_codes";
13+
14+
/*
15+
Create a new simple OIDC sessions table that will hold tokens + userinfo.
16+
*/
17+
18+
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
19+
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
20+
"access_token_hash" TEXT NOT NULL UNIQUE,
21+
"refresh_token_hash" TEXT NOT NULL UNIQUE,
22+
"scope" TEXT NOT NULL,
23+
"client_id" TEXT NOT NULL,
24+
"token_expires_at" BIGINT NOT NULL,
25+
"refresh_token_expires_at" BIGINT NOT NULL,
26+
"nonce" TEXT NOT NULL DEFAULT '',
27+
"userinfo_json" TEXT NOT NULL
28+
);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
DROP TABLE IF EXISTS "oidc_sessions";
2+
3+
CREATE TABLE IF NOT EXISTS "oidc_codes" (
4+
"sub" TEXT NOT NULL UNIQUE,
5+
"code_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
6+
"scope" TEXT NOT NULL,
7+
"redirect_uri" TEXT NOT NULL,
8+
"client_id" TEXT NOT NULL,
9+
"expires_at" INTEGER NOT NULL,
10+
"nonce" TEXT DEFAULT "",
11+
"code_challenge" TEXT DEFAULT ""
12+
);
13+
14+
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
15+
"sub" TEXT NOT NULL UNIQUE,
16+
"access_token_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
17+
"refresh_token_hash" TEXT NOT NULL,
18+
"code_hash" TEXT NOT NULL,
19+
"scope" TEXT NOT NULL,
20+
"client_id" TEXT NOT NULL,
21+
"token_expires_at" INTEGER NOT NULL,
22+
"refresh_token_expires_at" INTEGER NOT NULL,
23+
"nonce" TEXT DEFAULT ""
24+
);
25+
26+
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
27+
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
28+
"name" TEXT NOT NULL,
29+
"preferred_username" TEXT NOT NULL,
30+
"email" TEXT NOT NULL,
31+
"groups" TEXT NOT NULL,
32+
"updated_at" INTEGER NOT NULL,
33+
"given_name" TEXT NOT NULL,
34+
"family_name" TEXT NOT NULL,
35+
"middle_name" TEXT NOT NULL,
36+
"nickname" TEXT NOT NULL,
37+
"profile" TEXT NOT NULL,
38+
"picture" TEXT NOT NULL,
39+
"website" TEXT NOT NULL,
40+
"gender" TEXT NOT NULL,
41+
"birthdate" TEXT NOT NULL,
42+
"zoneinfo" TEXT NOT NULL,
43+
"locale" TEXT NOT NULL,
44+
"phone_number" TEXT NOT NULL,
45+
"address" TEXT NOT NULL
46+
);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
This migration will nuke the entire setup of OIDC sessions and merge everything
3+
into one table.
4+
*/
5+
6+
/*
7+
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
8+
*/
9+
10+
DROP TABLE IF EXISTS "oidc_tokens";
11+
DROP TABLE IF EXISTS "oidc_userinfo";
12+
DROP TABLE IF EXISTS "oidc_codes";
13+
14+
/*
15+
Create a new simple OIDC sessions table that will hold tokens + userinfo.
16+
*/
17+
18+
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
19+
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
20+
"access_token_hash" TEXT NOT NULL UNIQUE,
21+
"refresh_token_hash" TEXT NOT NULL UNIQUE,
22+
"scope" TEXT NOT NULL,
23+
"client_id" TEXT NOT NULL,
24+
"token_expires_at" INTEGER NOT NULL,
25+
"refresh_token_expires_at" INTEGER NOT NULL,
26+
"nonce" TEXT DEFAULT "",
27+
"userinfo_json" TEXT NOT NULL
28+
);

internal/controller/oidc_controller.go

Lines changed: 35 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package controller
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"net/http"
@@ -12,7 +13,6 @@ import (
1213

1314
"github.com/tinyauthapp/tinyauth/internal/model"
1415
"github.com/tinyauthapp/tinyauth/internal/service"
15-
"github.com/tinyauthapp/tinyauth/internal/utils"
1616
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
1717
)
1818

@@ -169,7 +169,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
169169
return
170170
}
171171

172-
client, ok := controller.oidc.GetClient(req.ClientID)
172+
_, ok := controller.oidc.GetClient(req.ClientID)
173173

174174
if !ok {
175175
controller.authorizeError(c, authorizeErrorParams{
@@ -203,9 +203,8 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
203203
return
204204
}
205205

206-
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
207-
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), client.ID))
208-
code := utils.GenerateString(32)
206+
// Create the sub to find and delete old sessions
207+
sub := controller.oidc.CreateSub(*userContext, req.ClientID)
209208

210209
// Before storing the code, delete old session
211210
err = controller.oidc.DeleteOldSession(c, sub)
@@ -221,37 +220,8 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
221220
return
222221
}
223222

224-
err = controller.oidc.StoreCode(c, sub, code, req)
225-
226-
if err != nil {
227-
controller.authorizeError(c, authorizeErrorParams{
228-
err: err,
229-
reason: "Failed to store code",
230-
reasonPublic: "Failed to store code",
231-
callback: req.RedirectURI,
232-
callbackError: "server_error",
233-
state: req.State,
234-
})
235-
return
236-
}
237-
238-
// We also need a snapshot of the user that authorized this (skip if no openid scope)
239-
if slices.Contains(strings.Fields(req.Scope), "openid") {
240-
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
241-
242-
if err != nil {
243-
controller.log.App.Error().Err(err).Msg("Failed to store user info")
244-
controller.authorizeError(c, authorizeErrorParams{
245-
err: err,
246-
reason: "Failed to store user info",
247-
reasonPublic: "Failed to store user info",
248-
callback: req.RedirectURI,
249-
callbackError: "server_error",
250-
state: req.State,
251-
})
252-
return
253-
}
254-
}
223+
// Create the authorization code
224+
code := controller.oidc.CreateCode(req, *userContext)
255225

256226
queries, err := query.Values(AuthorizeCallback{
257227
Code: code,
@@ -354,39 +324,34 @@ func (controller *OIDCController) Token(c *gin.Context) {
354324

355325
switch req.GrantType {
356326
case "authorization_code":
357-
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
358-
if err != nil {
359-
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
360-
controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code")
361-
}
362-
if errors.Is(err, service.ErrCodeNotFound) {
363-
controller.log.App.Warn().Msg("Code not found")
364-
c.JSON(400, gin.H{
365-
"error": "invalid_grant",
366-
})
367-
return
368-
}
369-
if errors.Is(err, service.ErrCodeExpired) {
370-
controller.log.App.Warn().Msg("Code expired")
327+
entry, ok := controller.oidc.GetCodeEntry(controller.oidc.Hash(req.Code), client.ClientID)
328+
329+
if !ok {
330+
// ensure no code reuse
331+
usedCodeSub, ok := controller.oidc.IsCodeUsed(controller.oidc.Hash(req.Code))
332+
333+
if ok {
334+
controller.log.App.Warn().Msg("Code reuse detected")
335+
err := controller.oidc.DeleteSessionBySub(c, usedCodeSub)
336+
if err != nil {
337+
controller.log.App.Error().Err(err).Msg("Failed to delete session for reused code")
338+
}
371339
c.JSON(400, gin.H{
372340
"error": "invalid_grant",
373341
})
374342
return
375343
}
376-
if errors.Is(err, service.ErrInvalidClient) {
377-
controller.log.App.Warn().Msg("Code does not belong to client")
378-
c.JSON(400, gin.H{
379-
"error": "invalid_client",
380-
})
381-
return
382-
}
383-
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
344+
345+
controller.log.App.Warn().Msg("Code not found")
384346
c.JSON(400, gin.H{
385-
"error": "server_error",
347+
"error": "invalid_grant",
386348
})
387349
return
388350
}
389351

352+
// mark code as used to prevent reuse
353+
controller.oidc.MarkCodeAsUsed(controller.oidc.Hash(req.Code), entry.Userinfo.Sub)
354+
390355
if entry.RedirectURI != req.RedirectURI {
391356
controller.log.App.Warn().Msg("Redirect URI does not match")
392357
c.JSON(400, gin.H{
@@ -395,7 +360,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
395360
return
396361
}
397362

398-
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
363+
ok = controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
399364

400365
if !ok {
401366
controller.log.App.Warn().Msg("PKCE validation failed")
@@ -405,7 +370,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
405370
return
406371
}
407372

408-
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
373+
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, *entry)
409374

410375
if err != nil {
411376
controller.log.App.Error().Err(err).Msg("Failed to generate access token")
@@ -415,7 +380,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
415380
return
416381
}
417382

418-
tokenResponse = tokenRes
383+
tokenResponse = *tokenRes
419384
case "refresh_token":
420385
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, creds.ClientID)
421386

@@ -443,7 +408,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
443408
return
444409
}
445410

446-
tokenResponse = tokenRes
411+
tokenResponse = *tokenRes
447412
}
448413

449414
c.Header("cache-control", "no-store")
@@ -507,7 +472,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
507472
return
508473
}
509474

510-
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
475+
entry, err := controller.oidc.GetSessionByToken(c, controller.oidc.Hash(token))
511476

512477
if err != nil {
513478
if errors.Is(err, service.ErrTokenNotFound) {
@@ -526,15 +491,17 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
526491
}
527492

528493
// If we don't have the openid scope, return an error
529-
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
530-
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
494+
if !slices.Contains(strings.Split(entry.Scope, " "), "openid") {
495+
controller.log.App.Warn().Msg("OIDC userinfo accessed with missing openid scope")
531496
c.JSON(401, gin.H{
532497
"error": "invalid_scope",
533498
})
534499
return
535500
}
536501

537-
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
502+
var userinfo service.UserinfoResponse
503+
504+
err = json.Unmarshal([]byte(entry.UserinfoJson), &userinfo)
538505

539506
if err != nil {
540507
controller.log.App.Error().Err(err).Msg("Failed to get user info")
@@ -544,7 +511,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
544511
return
545512
}
546513

547-
c.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))
514+
c.JSON(200, controller.oidc.CompileUserinfo(userinfo, entry.Scope))
548515
}
549516

550517
func (controller *OIDCController) authorizeError(c *gin.Context, params authorizeErrorParams) {

0 commit comments

Comments
 (0)