feat: do not show oidc consent screen every time (#989)

This commit is contained in:
Stavros
2026-08-17 02:44:48 +03:00
committed by GitHub
parent 61f65cfaa7
commit 0c47e68c09
26 changed files with 779 additions and 9 deletions
+1 -2
View File
@@ -190,8 +190,7 @@ export const AuthorizePage = () => {
<CardFooter className="flex flex-col items-stretch gap-3"> <CardFooter className="flex flex-col items-stretch gap-3">
<Button <Button
onClick={() => authorizeMutate()} onClick={() => authorizeMutate()}
loading={authorizePending} loading={authorizePending || shouldAutoAuthorize}
disabled={shouldAutoAuthorize}
> >
{t("authorizeTitle")} {t("authorizeTitle")}
</Button> </Button>
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "oidc_consents";
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" BIGINT NOT NULL,
PRIMARY KEY ("username", "client_id")
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "oidc_consents";
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
PRIMARY KEY ("username", "client_id")
);
-2
View File
@@ -179,8 +179,6 @@ func (app *BootstrapApp) Setup() error {
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId) app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId) app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
// database // database
+45
View File
@@ -242,6 +242,16 @@ func (controller *OIDCController) authorize(c *gin.Context) {
} }
} }
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
values.OIDCPrompt = service.OIDCPromptNone
}
}
queries, err := query.Values(values) queries, err := query.Values(values)
if err != nil { if err != nil {
@@ -320,6 +330,19 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return return
} }
// Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("client not found"),
reason: "Client not found",
reasonPublic: "The client is not configured",
json: true,
})
return
}
// We no longer need the ticket // We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket) controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
@@ -356,6 +379,11 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return return
} }
// Store the consent granted by the user for this client
if _, err := controller.oidc.UpsertOIDCConsent(c, userContext.GetUsername(), authorizeReq.Scope, client.ClientID); err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to store OIDC consent")
}
q := cu.Query() q := cu.Query()
q.Set("code", code) q.Set("code", code)
@@ -756,3 +784,20 @@ func (controller *OIDCController) resolveNormalParams(c *gin.Context) (*service.
return &req, nil return &req, nil
} }
// scopesGranted reports whether every scope in requested is present in the
// space-separated granted scope string.
func scopesGranted(granted, requested string) bool {
grantedScopes := strings.Split(granted, " ")
for _, scope := range strings.Split(requested, " ") {
if scope == "" {
continue
}
if !slices.Contains(grantedScopes, scope) {
return false
}
}
return true
}
@@ -170,6 +170,102 @@ func TestOIDCController(t *testing.T) {
assert.Contains(t, location, "oidc_name="+url.QueryEscape("Test Client")) assert.Contains(t, location, "oidc_name="+url.QueryEscape("Test Client"))
}, },
}, },
{
description: "Authorize skips the consent screen when all requested scopes were already granted",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when a new scope is requested",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.NotContains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize skips the consent screen for a subset of already granted scopes",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when no consent was granted yet",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.NotContains(t, location, "oidc_prompt=none")
},
},
{ {
description: "Authorize redirects to error screen when the request object is invalid", description: "Authorize redirects to error screen when the request object is invalid",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
-2
View File
@@ -20,8 +20,6 @@ var OverrideProviders = map[string]string{
var ReservedProviderNames = []string{"local", "ldap", "tailscale"} var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
const SessionCookieName = "tinyauth-session" const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth" const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds const GracefulShutdownTimeout = 5 // seconds
-2
View File
@@ -5,8 +5,6 @@ type RuntimeConfig struct {
UUID string UUID string
CookieDomain string CookieDomain string
SessionCookieName string SessionCookieName string
CSRFCookieName string
RedirectCookieName string
OAuthSessionCookieName string OAuthSessionCookieName string
LocalUsers []LocalUser LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig OAuthProviders map[string]OAuthServiceConfig
+74
View File
@@ -277,6 +277,80 @@ func TestMemoryStore(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
}, },
}, },
{
description: "Upsert creates a consent for each user+client pair",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid profile", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-b", Scope: "openid email", CreatedAt: 2,
})
require.NoError(t, err)
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 2)
gotA, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
require.NoError(t, err)
assert.Equal(t, "openid profile", gotA.Scope)
gotB, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-b"})
require.NoError(t, err)
assert.Equal(t, "openid email", gotB.Scope)
},
},
{
description: "Upsert overwrites the same consent row",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid email", CreatedAt: 2,
})
require.NoError(t, err)
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 1)
got, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
require.NoError(t, err)
assert.Equal(t, "openid email", got.Scope)
},
},
{
description: "Get consent by username and client not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete consent by client id",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-b", Scope: "openid", CreatedAt: 2,
})
require.NoError(t, err)
require.NoError(t, s.DeleteOIDCConsentByClientID(ctx, "client-a"))
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 1)
assert.Equal(t, "client-b", consents[0].ClientID)
},
},
} }
for _, test := range tests { for _, test := range tests {
@@ -94,3 +94,46 @@ func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.Dele
} }
return nil return nil
} }
func consentKey(username, clientID string) string {
return username + "\x00" + clientID
}
func (s *Store) UpsertOIDCConsent(_ context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
s.mu.Lock()
defer s.mu.Unlock()
oc := repository.OidcConsent(arg)
s.oidcConsents[consentKey(arg.Username, arg.ClientID)] = oc
return oc, nil
}
func (s *Store) GetOIDCConsentByUsernameAndClientID(_ context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
oc, ok := s.oidcConsents[consentKey(arg.Username, arg.ClientID)]
if !ok {
return repository.OidcConsent{}, repository.ErrNotFound
}
return oc, nil
}
func (s *Store) DeleteOIDCConsentByClientID(_ context.Context, clientID string) error {
s.mu.Lock()
defer s.mu.Unlock()
for key, oc := range s.oidcConsents {
if oc.ClientID == clientID {
delete(s.oidcConsents, key)
}
}
return nil
}
func (s *Store) ListOIDCConsents(_ context.Context) ([]repository.OidcConsent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]repository.OidcConsent, 0, len(s.oidcConsents))
for _, oc := range s.oidcConsents {
out = append(out, oc)
}
return out, nil
}
+2
View File
@@ -12,6 +12,7 @@ type Store struct {
mu sync.RWMutex mu sync.RWMutex
sessions map[string]repository.Session sessions map[string]repository.Session
oidcSessions map[string]repository.OidcSession oidcSessions map[string]repository.OidcSession
oidcConsents map[string]repository.OidcConsent
} }
// New returns a new empty in-memory Store. // New returns a new empty in-memory Store.
@@ -19,5 +20,6 @@ func New() repository.Store {
return &Store{ return &Store{
sessions: make(map[string]repository.Session), sessions: make(map[string]repository.Session),
oidcSessions: make(map[string]repository.OidcSession), oidcSessions: make(map[string]repository.OidcSession),
oidcConsents: make(map[string]repository.OidcConsent),
} }
} }
+19
View File
@@ -84,3 +84,22 @@ type DeleteExpiredOIDCSessionsParams struct {
TokenExpiresAt int64 TokenExpiresAt int64
RefreshTokenExpiresAt int64 RefreshTokenExpiresAt int64
} }
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
+7
View File
@@ -4,6 +4,13 @@
package postgres package postgres
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type OidcSession struct { type OidcSession struct {
Sub string Sub string
AccessTokenHash string AccessTokenHash string
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
return err return err
} }
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = $1
`
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions" DELETE FROM "oidc_sessions"
WHERE "sub" = $1 WHERE "sub" = $1
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
return err return err
} }
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT username, client_id, scope, created_at FROM "oidc_consents"
WHERE "username" = $1 AND "client_id" = $2
`
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions" SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = $1 WHERE "access_token_hash" = $1
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
return i, err return i, err
} }
const listOIDCConsents = `-- name: ListOIDCConsents :many
SELECT username, client_id, scope, created_at FROM "oidc_consents"
`
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcConsent
for rows.Next() {
var i OidcConsent
if err := rows.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET UPDATE "oidc_sessions" SET
"access_token_hash" = $1, "access_token_hash" = $1,
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
) )
return i, err return i, err
} }
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
$1, $2, $3, $4
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING username, client_id, scope, created_at
`
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
arg.Username,
arg.ClientID,
arg.Scope,
arg.CreatedAt,
)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
+32
View File
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry)) return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
} }
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error { func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub)) return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
} }
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid)) return mapErr(s.q.DeleteSession(ctx, uuid))
} }
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) { func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash) r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil { if err != nil {
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
return repository.Session(r), nil return repository.Session(r), nil
} }
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
rows, err := s.q.ListOIDCConsents(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcConsent, len(rows))
for i, row := range rows {
out[i] = repository.OidcConsent(row)
}
return out, nil
}
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) { func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg)) r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil { if err != nil {
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
} }
return repository.Session(r), nil return repository.Session(r), nil
} }
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
+7
View File
@@ -4,6 +4,13 @@
package sqlite package sqlite
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type OidcSession struct { type OidcSession struct {
Sub string Sub string
AccessTokenHash string AccessTokenHash string
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
return err return err
} }
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = ?
`
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions" DELETE FROM "oidc_sessions"
WHERE "sub" = ? WHERE "sub" = ?
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
return err return err
} }
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT username, client_id, scope, created_at FROM "oidc_consents"
WHERE "username" = ? AND "client_id" = ?
`
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions" SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = ? WHERE "access_token_hash" = ?
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
return i, err return i, err
} }
const listOIDCConsents = `-- name: ListOIDCConsents :many
SELECT username, client_id, scope, created_at FROM "oidc_consents"
`
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcConsent
for rows.Next() {
var i OidcConsent
if err := rows.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET UPDATE "oidc_sessions" SET
"access_token_hash" = ?, "access_token_hash" = ?,
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
) )
return i, err return i, err
} }
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
?, ?, ?, ?
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING username, client_id, scope, created_at
`
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
arg.Username,
arg.ClientID,
arg.Scope,
arg.CreatedAt,
)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
+32
View File
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry)) return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
} }
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error { func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub)) return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
} }
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid)) return mapErr(s.q.DeleteSession(ctx, uuid))
} }
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) { func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash) r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil { if err != nil {
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
return repository.Session(r), nil return repository.Session(r), nil
} }
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
rows, err := s.q.ListOIDCConsents(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcConsent, len(rows))
for i, row := range rows {
out[i] = repository.OidcConsent(row)
}
return out, nil
}
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) { func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg)) r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil { if err != nil {
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
} }
return repository.Session(r), nil return repository.Session(r), nil
} }
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
+6
View File
@@ -27,4 +27,10 @@ type Store interface {
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
// OIDC Consents
UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error)
GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error)
DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error
ListOIDCConsents(ctx context.Context) ([]OidcConsent, error)
} }
+122 -1
View File
@@ -16,6 +16,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"slices" "slices"
@@ -163,6 +164,10 @@ type OIDCService struct {
usedCode *cache.CacheStore[UsedCodeEntry] usedCode *cache.CacheStore[UsedCodeEntry]
authorize *cache.CacheStore[AuthorizeRequest] authorize *cache.CacheStore[AuthorizeRequest]
} }
mus struct {
consent sync.RWMutex
}
} }
type OIDCServiceInput struct { type OIDCServiceInput struct {
@@ -336,6 +341,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
issuer: issuer, issuer: issuer,
} }
// Remove consents for clients that are no longer configured
if err := service.reconcileOIDCConsents(context.Background()); err != nil {
i.Log.App.Warn().Err(err).Msg("Failed to reconcile OIDC consents")
}
// Start cleanup routine // Start cleanup routine
i.Ding.Go(service.cleanupRoutine, ding.RingMinor) i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
@@ -920,7 +930,7 @@ func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) {
service.caches.authorize.Delete(ticket) service.caches.authorize.Delete(ticket)
} }
// TODO: support signed request objects in the future // DecodeAuthorizeJWT TODO: support signed request objects in the future
func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) { func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) {
var claims jwt.MapClaims var claims jwt.MapClaims
@@ -970,3 +980,114 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
return parsedPromps return parsedPromps
} }
func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
entry, err := service.queries.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
Username: username,
ClientID: clientId,
})
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("failed to get oidc consent: %w", err)
}
return &entry, nil
}
func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
service.mus.consent.RLock()
defer service.mus.consent.RUnlock()
return service.getOIDCConsentUnsafe(ctx, username, clientId)
}
func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, scope, clientId string) (repository.OidcConsent, error) {
service.mus.consent.Lock()
defer service.mus.consent.Unlock()
existing, err := service.getOIDCConsentUnsafe(ctx, username, clientId)
if err != nil {
return repository.OidcConsent{}, err
}
merged := scope
if existing != nil {
merged = mergeScopes(existing.Scope, scope)
}
entry := repository.UpsertOIDCConsentParams{
Username: username,
Scope: merged,
ClientID: clientId,
CreatedAt: time.Now().Unix(),
}
consent, err := service.queries.UpsertOIDCConsent(ctx, entry)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent")
return repository.OidcConsent{}, err
}
return consent, nil
}
func (service *OIDCService) reconcileOIDCConsents(ctx context.Context) error {
consents, err := service.queries.ListOIDCConsents(ctx)
if err != nil {
return fmt.Errorf("failed to list oidc consents: %w", err)
}
cleaned := make(map[string]struct{})
for _, consent := range consents {
if _, ok := cleaned[consent.ClientID]; ok {
continue
}
cleaned[consent.ClientID] = struct{}{}
if _, ok := service.clients[consent.ClientID]; ok {
continue
}
service.log.App.Info().Str("clientId", consent.ClientID).Msg("Removed OIDC client no longer in configuration, deleting its consents")
if err := service.queries.DeleteOIDCConsentByClientID(ctx, consent.ClientID); err != nil {
service.log.App.Warn().Err(err).Str("clientId", consent.ClientID).Msg("Failed to delete OIDC consents for removed client")
}
}
return nil
}
func mergeScopes(existing, requested string) string {
set := make(map[string]struct{})
for _, scope := range strings.Split(existing, " ") {
if scope != "" {
set[scope] = struct{}{}
}
}
for _, scope := range strings.Split(requested, " ") {
if scope != "" {
set[scope] = struct{}{}
}
}
scopes := make([]string, 0, len(set))
for scope := range set {
scopes = append(scopes, scope)
}
slices.Sort(scopes)
return strings.Join(scopes, " ")
}
+26
View File
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
"userinfo_json" = $8 "userinfo_json" = $8
WHERE "sub" = $9 WHERE "sub" = $9
RETURNING *; RETURNING *;
-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
$1, $2, $3, $4
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING *;
-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT * FROM "oidc_consents"
WHERE "username" = $1 AND "client_id" = $2;
-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = $1;
-- name: ListOIDCConsents :many
SELECT * FROM "oidc_consents";
+9
View File
@@ -9,3 +9,12 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"nonce" TEXT NOT NULL DEFAULT '', "nonce" TEXT NOT NULL DEFAULT '',
"userinfo_json" TEXT NOT NULL "userinfo_json" TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" BIGINT NOT NULL,
PRIMARY KEY ("username", "client_id")
);
+26
View File
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
"userinfo_json" = ? "userinfo_json" = ?
WHERE "sub" = ? WHERE "sub" = ?
RETURNING *; RETURNING *;
-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
?, ?, ?, ?
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING *;
-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT * FROM "oidc_consents"
WHERE "username" = ? AND "client_id" = ?;
-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = ?;
-- name: ListOIDCConsents :many
SELECT * FROM "oidc_consents";
+8
View File
@@ -9,3 +9,11 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"nonce" TEXT NOT NULL DEFAULT "", "nonce" TEXT NOT NULL DEFAULT "",
"userinfo_json" TEXT NOT NULL "userinfo_json" TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
PRIMARY KEY ("username", "client_id")
);