From 80fef66815be3ca8e724df44d93167b604bacbe5 Mon Sep 17 00:00:00 2001 From: Stavros Date: Tue, 3 Nov 2026 22:50:52 +0200 Subject: [PATCH 01/16] wip: initial schema --- .../postgres/000004_identity.down.sql | 4 + .../postgres/000004_identity.up.sql | 41 ++ .../sqlite/000012_identity.down.sql | 4 + .../migrations/sqlite/000012_identity.up.sql | 41 ++ internal/repository/memory/memory_test.go | 362 ------------------ internal/repository/memory/oidc_queries.go | 139 ------- internal/repository/memory/session_queries.go | 63 --- internal/repository/memory/store.go | 25 -- internal/repository/models.go | 98 +++-- .../postgres/identities.queries.sql.go | 68 ++++ internal/repository/postgres/models.go | 36 +- ...idc_queries.sql.go => oidc.queries.sql.go} | 36 +- .../postgres/session.queries.sql.go | 93 +++++ .../postgres/session_queries.sql.go | 176 --------- internal/repository/postgres/store.go | 100 +++-- .../repository/postgres/users.queries.sql.go | 63 +++ .../sqlite/identities.queries.sql.go | 68 ++++ internal/repository/sqlite/models.go | 36 +- ...idc_queries.sql.go => oidc.queries.sql.go} | 36 +- .../repository/sqlite/session.queries.sql.go | 93 +++++ .../repository/sqlite/session_queries.sql.go | 176 --------- internal/repository/sqlite/store.go | 100 +++-- .../repository/sqlite/users.queries.sql.go | 63 +++ internal/repository/store.go | 34 +- sql/postgres/identities.queries.sql | 15 + sql/postgres/identities.schemas.sql | 8 + .../{oidc_queries.sql => oidc.queries.sql} | 0 .../{oidc_schemas.sql => oidc.schemas.sql} | 0 sql/postgres/session.queries.sql | 24 ++ sql/postgres/session.schemas.sql | 9 + sql/postgres/session_queries.sql | 43 --- sql/postgres/users.queries.sql | 14 + sql/postgres/users.schemas.sql | 6 + sql/sqlite/identities.queries.sql | 15 + sql/sqlite/identities.schemas.sql | 8 + .../{oidc_queries.sql => oidc.queries.sql} | 0 .../{oidc_schemas.sql => oidc.schemas.sql} | 0 sql/sqlite/session.queries.sql | 24 ++ sql/sqlite/session.schemas.sql | 9 + sql/sqlite/session_queries.sql | 43 --- sql/sqlite/users.queries.sql | 14 + sql/sqlite/users.schemas.sql | 6 + sqlc.yml | 32 +- 43 files changed, 987 insertions(+), 1238 deletions(-) rename sql/postgres/session_schemas.sql => internal/assets/migrations/postgres/000004_identity.down.sql (83%) create mode 100644 internal/assets/migrations/postgres/000004_identity.up.sql rename sql/sqlite/session_schemas.sql => internal/assets/migrations/sqlite/000012_identity.down.sql (79%) create mode 100644 internal/assets/migrations/sqlite/000012_identity.up.sql delete mode 100644 internal/repository/memory/memory_test.go delete mode 100644 internal/repository/memory/oidc_queries.go delete mode 100644 internal/repository/memory/session_queries.go delete mode 100644 internal/repository/memory/store.go create mode 100644 internal/repository/postgres/identities.queries.sql.go rename internal/repository/postgres/{oidc_queries.sql.go => oidc.queries.sql.go} (92%) create mode 100644 internal/repository/postgres/session.queries.sql.go delete mode 100644 internal/repository/postgres/session_queries.sql.go create mode 100644 internal/repository/postgres/users.queries.sql.go create mode 100644 internal/repository/sqlite/identities.queries.sql.go rename internal/repository/sqlite/{oidc_queries.sql.go => oidc.queries.sql.go} (92%) create mode 100644 internal/repository/sqlite/session.queries.sql.go delete mode 100644 internal/repository/sqlite/session_queries.sql.go create mode 100644 internal/repository/sqlite/users.queries.sql.go create mode 100644 sql/postgres/identities.queries.sql create mode 100644 sql/postgres/identities.schemas.sql rename sql/postgres/{oidc_queries.sql => oidc.queries.sql} (100%) rename sql/postgres/{oidc_schemas.sql => oidc.schemas.sql} (100%) create mode 100644 sql/postgres/session.queries.sql create mode 100644 sql/postgres/session.schemas.sql delete mode 100644 sql/postgres/session_queries.sql create mode 100644 sql/postgres/users.queries.sql create mode 100644 sql/postgres/users.schemas.sql create mode 100644 sql/sqlite/identities.queries.sql create mode 100644 sql/sqlite/identities.schemas.sql rename sql/sqlite/{oidc_queries.sql => oidc.queries.sql} (100%) rename sql/sqlite/{oidc_schemas.sql => oidc.schemas.sql} (100%) create mode 100644 sql/sqlite/session.queries.sql create mode 100644 sql/sqlite/session.schemas.sql delete mode 100644 sql/sqlite/session_queries.sql create mode 100644 sql/sqlite/users.queries.sql create mode 100644 sql/sqlite/users.schemas.sql diff --git a/sql/postgres/session_schemas.sql b/internal/assets/migrations/postgres/000004_identity.down.sql similarity index 83% rename from sql/postgres/session_schemas.sql rename to internal/assets/migrations/postgres/000004_identity.down.sql index 925bcd747..94632fdf5 100644 --- a/sql/postgres/session_schemas.sql +++ b/internal/assets/migrations/postgres/000004_identity.down.sql @@ -1,3 +1,7 @@ +DROP TABLE IF EXISTS "users"; +DROP TABLE IF EXISTS "identities"; +DROP TABLE IF EXISTS "sessions"; + CREATE TABLE IF NOT EXISTS "sessions" ( "uuid" TEXT NOT NULL PRIMARY KEY, "username" TEXT NOT NULL, diff --git a/internal/assets/migrations/postgres/000004_identity.up.sql b/internal/assets/migrations/postgres/000004_identity.up.sql new file mode 100644 index 000000000..598e7eef8 --- /dev/null +++ b/internal/assets/migrations/postgres/000004_identity.up.sql @@ -0,0 +1,41 @@ +/* + Drop the old, legacy, terrible sessions table + + Will log out all users, but that's fine. + */ + +DROP TABLE IF EXISTS "sessions"; + +/* + The new structure consists of the following: + + Users: holds the user data like the username, password, etc. + Identities: links the user to the identity provider. + Sessions: holds the session data like the UUID, CSRF, expiration time, etc. + */ + +CREATE TABLE IF NOT EXISTS "users" ( + "uuid" TEXT PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "identities" ( + "uuid" TEXT PRIMARY KEY, + "user_uuid" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_user_id" TEXT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("user_uuid") REFERENCES "users"("uuid") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "uuid" TEXT PRIMARY KEY, + "identity_uuid" TEXT NOT NULL, + "csrf_token" TEXT NOT NULL, + "expires_at" BIGINT NOT NULL, + "created_at" BIGINT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("identity_uuid") REFERENCES "identities"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/sqlite/session_schemas.sql b/internal/assets/migrations/sqlite/000012_identity.down.sql similarity index 79% rename from sql/sqlite/session_schemas.sql rename to internal/assets/migrations/sqlite/000012_identity.down.sql index a7f37eb7b..39756584e 100644 --- a/sql/sqlite/session_schemas.sql +++ b/internal/assets/migrations/sqlite/000012_identity.down.sql @@ -1,3 +1,7 @@ +DROP TABLE IF EXISTS "users"; +DROP TABLE IF EXISTS "identities"; +DROP TABLE IF EXISTS "sessions"; + CREATE TABLE IF NOT EXISTS "sessions" ( "uuid" TEXT NOT NULL PRIMARY KEY UNIQUE, "username" TEXT NOT NULL, diff --git a/internal/assets/migrations/sqlite/000012_identity.up.sql b/internal/assets/migrations/sqlite/000012_identity.up.sql new file mode 100644 index 000000000..5ae39858c --- /dev/null +++ b/internal/assets/migrations/sqlite/000012_identity.up.sql @@ -0,0 +1,41 @@ +/* + Drop the old, legacy, terrible sessions table + + Will log out all users, but that's fine. + */ + +DROP TABLE IF EXISTS "sessions"; + +/* + The new structure consists of the following: + + Users: holds the user data like the username, password, etc. + Identities: links the user to the identity provider. + Sessions: holds the session data like the UUID, CSRF, expiration time, etc. + */ + +CREATE TABLE IF NOT EXISTS "users" ( + "uuid" TEXT PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "identities" ( + "uuid" TEXT PRIMARY KEY, + "user_uuid" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_user_id" TEXT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("user_uuid") REFERENCES "users"("uuid") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "uuid" TEXT PRIMARY KEY, + "identity_uuid" TEXT NOT NULL, + "csrf_token" TEXT NOT NULL, + "expires_at" INTEGER NOT NULL, + "created_at" INTEGER NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("identity_uuid") REFERENCES "identities"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/internal/repository/memory/memory_test.go b/internal/repository/memory/memory_test.go deleted file mode 100644 index c2ae4ca8d..000000000 --- a/internal/repository/memory/memory_test.go +++ /dev/null @@ -1,362 +0,0 @@ -package memory_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/tinyauthapp/tinyauth/internal/repository" - "github.com/tinyauthapp/tinyauth/internal/repository/memory" -) - -var ctx = context.Background() - -func TestMemoryStore(t *testing.T) { - type testCase struct { - description string - run func(t *testing.T, s repository.Store) - } - - tests := []testCase{ - { - description: "Create and get session", - run: func(t *testing.T, s repository.Store) { - sess, err := s.CreateSession(ctx, repository.CreateSessionParams{ - UUID: "uuid-1", - Username: "alice", - Expiry: 9999, - }) - require.NoError(t, err) - assert.Equal(t, "uuid-1", sess.UUID) - assert.Equal(t, "alice", sess.Username) - - got, err := s.GetSession(ctx, "uuid-1") - require.NoError(t, err) - assert.Equal(t, sess, got) - }, - }, - { - description: "Get session not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.GetSession(ctx, "missing") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Update session", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "uuid-1", Username: "alice"}) - require.NoError(t, err) - - updated, err := s.UpdateSession(ctx, repository.UpdateSessionParams{ - UUID: "uuid-1", - Username: "bob", - Email: "bob@example.com", - }) - require.NoError(t, err) - assert.Equal(t, "bob", updated.Username) - assert.Equal(t, "bob@example.com", updated.Email) - - got, err := s.GetSession(ctx, "uuid-1") - require.NoError(t, err) - assert.Equal(t, updated, got) - }, - }, - { - description: "Update session not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.UpdateSession(ctx, repository.UpdateSessionParams{UUID: "missing"}) - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Delete session", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "uuid-1"}) - require.NoError(t, err) - - require.NoError(t, s.DeleteSession(ctx, "uuid-1")) - - _, err = s.GetSession(ctx, "uuid-1") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Delete expired sessions", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "expired", Expiry: 10}) - require.NoError(t, err) - _, err = s.CreateSession(ctx, repository.CreateSessionParams{UUID: "valid", Expiry: 100}) - require.NoError(t, err) - - require.NoError(t, s.DeleteExpiredSessions(ctx, 50)) - - _, err = s.GetSession(ctx, "expired") - assert.ErrorIs(t, err, repository.ErrNotFound) - - _, err = s.GetSession(ctx, "valid") - assert.NoError(t, err) - }, - }, - { - description: "Create and get OIDC session", - run: func(t *testing.T, s repository.Store) { - sess, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-1", - AccessTokenHash: "at-1", - RefreshTokenHash: "rt-1", - Scope: "openid", - }) - require.NoError(t, err) - assert.Equal(t, "sub-1", sess.Sub) - - got, err := s.GetOIDCSessionBySub(ctx, "sub-1") - require.NoError(t, err) - assert.Equal(t, sess, got) - }, - }, - { - description: "Get OIDC session by sub not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.GetOIDCSessionBySub(ctx, "missing") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Get OIDC session by access token hash", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-1", - AccessTokenHash: "at-1", - RefreshTokenHash: "rt-1", - }) - require.NoError(t, err) - - got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-1") - require.NoError(t, err) - assert.Equal(t, "sub-1", got.Sub) - }, - }, - { - description: "Get OIDC session by access token hash not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.GetOIDCSessionByAccessTokenHash(ctx, "missing") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Get OIDC session by refresh token hash", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-1", - AccessTokenHash: "at-1", - RefreshTokenHash: "rt-1", - }) - require.NoError(t, err) - - got, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "rt-1") - require.NoError(t, err) - assert.Equal(t, "sub-1", got.Sub) - }, - }, - { - description: "Get OIDC session by refresh token hash not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "missing") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Create OIDC session unique sub constraint", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"}) - require.NoError(t, err) - - _, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2"}) - assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.sub") - }, - }, - { - description: "Create OIDC session unique access token hash constraint", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"}) - require.NoError(t, err) - - _, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-1", RefreshTokenHash: "rt-2"}) - assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.access_token_hash") - }, - }, - { - description: "Create OIDC session unique refresh token hash constraint", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"}) - require.NoError(t, err) - - _, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-1"}) - assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.refresh_token_hash") - }, - }, - { - description: "Update OIDC session", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-1", - AccessTokenHash: "at-1", - RefreshTokenHash: "rt-1", - }) - require.NoError(t, err) - - updated, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{ - Sub: "sub-1", - AccessTokenHash: "at-2", - RefreshTokenHash: "rt-2", - Scope: "openid profile", - TokenExpiresAt: 200, - RefreshTokenExpiresAt: 400, - }) - require.NoError(t, err) - assert.Equal(t, "at-2", updated.AccessTokenHash) - assert.Equal(t, "rt-2", updated.RefreshTokenHash) - assert.Equal(t, "openid profile", updated.Scope) - - // updated token hashes are now queryable, old ones are gone - got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-2") - require.NoError(t, err) - assert.Equal(t, "sub-1", got.Sub) - - _, err = s.GetOIDCSessionByAccessTokenHash(ctx, "at-1") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Update OIDC session not found", - run: func(t *testing.T, s repository.Store) { - _, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{Sub: "missing"}) - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Delete OIDC session by sub", - run: func(t *testing.T, s repository.Store) { - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"}) - require.NoError(t, err) - - require.NoError(t, s.DeleteOIDCSessionBySub(ctx, "sub-1")) - - _, err = s.GetOIDCSessionBySub(ctx, "sub-1") - assert.ErrorIs(t, err, repository.ErrNotFound) - }, - }, - { - description: "Delete expired OIDC sessions", - run: func(t *testing.T, s repository.Store) { - // both expiries past - _, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1", - TokenExpiresAt: 10, RefreshTokenExpiresAt: 10, - }) - require.NoError(t, err) - // valid - _, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ - Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2", - TokenExpiresAt: 100, RefreshTokenExpiresAt: 100, - }) - require.NoError(t, err) - - require.NoError(t, s.DeleteExpiredOIDCSessions(ctx, repository.DeleteExpiredOIDCSessionsParams{ - TokenExpiresAt: 50, - RefreshTokenExpiresAt: 50, - })) - - _, err = s.GetOIDCSessionBySub(ctx, "sub-1") - assert.ErrorIs(t, err, repository.ErrNotFound) - - _, err = s.GetOIDCSessionBySub(ctx, "sub-2") - 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 { - t.Run(test.description, func(t *testing.T) { - s := memory.New() - test.run(t, s) - }) - } -} diff --git a/internal/repository/memory/oidc_queries.go b/internal/repository/memory/oidc_queries.go deleted file mode 100644 index e06a4346c..000000000 --- a/internal/repository/memory/oidc_queries.go +++ /dev/null @@ -1,139 +0,0 @@ -package memory - -import ( - "context" - "fmt" - - "github.com/tinyauthapp/tinyauth/internal/repository" -) - -func (s *Store) CreateOIDCSession(_ context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) { - s.mu.Lock() - defer s.mu.Unlock() - // Enforce UNIQUE constraints (sub is the primary key, access/refresh token hashes are unique). - for _, sess := range s.oidcSessions { - switch { - case sess.Sub == arg.Sub: - return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.sub") - case sess.AccessTokenHash == arg.AccessTokenHash: - return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.access_token_hash") - case sess.RefreshTokenHash == arg.RefreshTokenHash: - return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.refresh_token_hash") - } - } - sess := repository.OidcSession(arg) - s.oidcSessions[arg.Sub] = sess - return sess, nil -} - -func (s *Store) GetOIDCSessionBySub(_ context.Context, sub string) (repository.OidcSession, error) { - s.mu.RLock() - defer s.mu.RUnlock() - sess, ok := s.oidcSessions[sub] - if !ok { - return repository.OidcSession{}, repository.ErrNotFound - } - return sess, nil -} - -func (s *Store) GetOIDCSessionByAccessTokenHash(_ context.Context, accessTokenHash string) (repository.OidcSession, error) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, sess := range s.oidcSessions { - if sess.AccessTokenHash == accessTokenHash { - return sess, nil - } - } - return repository.OidcSession{}, repository.ErrNotFound -} - -func (s *Store) GetOIDCSessionByRefreshTokenHash(_ context.Context, refreshTokenHash string) (repository.OidcSession, error) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, sess := range s.oidcSessions { - if sess.RefreshTokenHash == refreshTokenHash { - return sess, nil - } - } - return repository.OidcSession{}, repository.ErrNotFound -} - -func (s *Store) UpdateOIDCSession(_ context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) { - s.mu.Lock() - defer s.mu.Unlock() - sess, ok := s.oidcSessions[arg.Sub] - if !ok { - return repository.OidcSession{}, repository.ErrNotFound - } - sess.AccessTokenHash = arg.AccessTokenHash - sess.RefreshTokenHash = arg.RefreshTokenHash - sess.Scope = arg.Scope - sess.ClientID = arg.ClientID - sess.TokenExpiresAt = arg.TokenExpiresAt - sess.RefreshTokenExpiresAt = arg.RefreshTokenExpiresAt - sess.Nonce = arg.Nonce - sess.UserinfoJson = arg.UserinfoJson - s.oidcSessions[arg.Sub] = sess - return sess, nil -} - -func (s *Store) DeleteOIDCSessionBySub(_ context.Context, sub string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.oidcSessions, sub) - return nil -} - -func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error { - s.mu.Lock() - defer s.mu.Unlock() - for k, sess := range s.oidcSessions { - if sess.TokenExpiresAt < arg.TokenExpiresAt && sess.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt { - delete(s.oidcSessions, k) - } - } - 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 -} diff --git a/internal/repository/memory/session_queries.go b/internal/repository/memory/session_queries.go deleted file mode 100644 index 2edde6b11..000000000 --- a/internal/repository/memory/session_queries.go +++ /dev/null @@ -1,63 +0,0 @@ -package memory - -import ( - "context" - - "github.com/tinyauthapp/tinyauth/internal/repository" -) - -func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) { - s.mu.Lock() - defer s.mu.Unlock() - sess := repository.Session(arg) - s.sessions[arg.UUID] = sess - return sess, nil -} - -func (s *Store) GetSession(_ context.Context, uuid string) (repository.Session, error) { - s.mu.RLock() - defer s.mu.RUnlock() - sess, ok := s.sessions[uuid] - if !ok { - return repository.Session{}, repository.ErrNotFound - } - return sess, nil -} - -func (s *Store) UpdateSession(_ context.Context, arg repository.UpdateSessionParams) (repository.Session, error) { - s.mu.Lock() - defer s.mu.Unlock() - sess, ok := s.sessions[arg.UUID] - if !ok { - return repository.Session{}, repository.ErrNotFound - } - sess.Username = arg.Username - sess.Email = arg.Email - sess.Name = arg.Name - sess.Provider = arg.Provider - sess.TotpPending = arg.TotpPending - sess.OAuthGroups = arg.OAuthGroups - sess.Expiry = arg.Expiry - sess.OAuthName = arg.OAuthName - sess.OAuthSub = arg.OAuthSub - s.sessions[arg.UUID] = sess - return sess, nil -} - -func (s *Store) DeleteSession(_ context.Context, uuid string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.sessions, uuid) - return nil -} - -func (s *Store) DeleteExpiredSessions(_ context.Context, expiry int64) error { - s.mu.Lock() - defer s.mu.Unlock() - for k, v := range s.sessions { - if v.Expiry < expiry { - delete(s.sessions, k) - } - } - return nil -} diff --git a/internal/repository/memory/store.go b/internal/repository/memory/store.go deleted file mode 100644 index 5aa4750b4..000000000 --- a/internal/repository/memory/store.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package memory provides an in-memory implementation of repository.Store for use in tests. -package memory - -import ( - "sync" - - "github.com/tinyauthapp/tinyauth/internal/repository" -) - -// Store is a thread-safe in-memory implementation of repository.Store. -type Store struct { - mu sync.RWMutex - sessions map[string]repository.Session - oidcSessions map[string]repository.OidcSession - oidcConsents map[string]repository.OidcConsent -} - -// New returns a new empty in-memory Store. -func New() repository.Store { - return &Store{ - sessions: make(map[string]repository.Session), - oidcSessions: make(map[string]repository.OidcSession), - oidcConsents: make(map[string]repository.OidcConsent), - } -} diff --git a/internal/repository/models.go b/internal/repository/models.go index 9e356680a..635a13c1d 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -3,21 +3,63 @@ package repository // Shared model and parameter types for all storage drivers. // sqlc-generated driver packages use these via the conversion layer in their store.go. +// Users + +type User struct { + UUID string + Username string + Email string + Name string +} + +type CreateUserParams struct { + UUID string + Username string + Email string + Name string +} + +// Identities + +type Identity struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +type CreateIdentityParams struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +// Sessions + type Session struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string +} + +type CreateSessionParams struct { + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string } -type OidcSession struct { +// OIDC + +type OIDCSession struct { Sub string AccessTokenHash string RefreshTokenHash string @@ -28,34 +70,6 @@ type OidcSession struct { Nonce string UserinfoJson string } - -type CreateSessionParams struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string -} - -type UpdateSessionParams struct { - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - OAuthName string - OAuthSub string - UUID string -} - type CreateOIDCSessionParams struct { Sub string AccessTokenHash string @@ -85,7 +99,9 @@ type DeleteExpiredOIDCSessionsParams struct { RefreshTokenExpiresAt int64 } -type OidcConsent struct { +// OIDC Consent + +type OIDCConsent struct { Username string ClientID string Scope string diff --git a/internal/repository/postgres/identities.queries.sql.go b/internal/repository/postgres/identities.queries.sql.go new file mode 100644 index 000000000..27589e444 --- /dev/null +++ b/internal/repository/postgres/identities.queries.sql.go @@ -0,0 +1,68 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: identities.queries.sql + +package postgres + +import ( + "context" +) + +const createIdentity = `-- name: CreateIdentity :one +INSERT INTO "identities" ( + "uuid", + "user_uuid", + "provider", + "provider_user_id", + "context_json" +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING uuid, user_uuid, provider, provider_user_id, context_json +` + +type CreateIdentityParams struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) { + row := q.db.QueryRowContext(ctx, createIdentity, + arg.UUID, + arg.UserUUID, + arg.Provider, + arg.ProviderUserID, + arg.ContextJSON, + ) + var i Identity + err := row.Scan( + &i.UUID, + &i.UserUUID, + &i.Provider, + &i.ProviderUserID, + &i.ContextJSON, + ) + return i, err +} + +const getIdentityByUUID = `-- name: GetIdentityByUUID :one +SELECT uuid, user_uuid, provider, provider_user_id, context_json FROM "identities" +WHERE "uuid" = $1 +` + +func (q *Queries) GetIdentityByUUID(ctx context.Context, uuid string) (Identity, error) { + row := q.db.QueryRowContext(ctx, getIdentityByUUID, uuid) + var i Identity + err := row.Scan( + &i.UUID, + &i.UserUUID, + &i.Provider, + &i.ProviderUserID, + &i.ContextJSON, + ) + return i, err +} diff --git a/internal/repository/postgres/models.go b/internal/repository/postgres/models.go index ccf7ce62a..4fa6f0103 100644 --- a/internal/repository/postgres/models.go +++ b/internal/repository/postgres/models.go @@ -4,14 +4,22 @@ package postgres -type OidcConsent struct { +type Identity struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +type OIDCConsent struct { Username string ClientID string Scope string CreatedAt int64 } -type OidcSession struct { +type OIDCSession struct { Sub string AccessTokenHash string RefreshTokenHash string @@ -24,15 +32,17 @@ type OidcSession struct { } type Session struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string +} + +type User struct { + UUID string + Username string + Email string + Name string } diff --git a/internal/repository/postgres/oidc_queries.sql.go b/internal/repository/postgres/oidc.queries.sql.go similarity index 92% rename from internal/repository/postgres/oidc_queries.sql.go rename to internal/repository/postgres/oidc.queries.sql.go index a2bb96314..1533541a5 100644 --- a/internal/repository/postgres/oidc_queries.sql.go +++ b/internal/repository/postgres/oidc.queries.sql.go @@ -1,7 +1,7 @@ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.31.1 -// source: oidc_queries.sql +// source: oidc.queries.sql package postgres @@ -38,7 +38,7 @@ type CreateOIDCSessionParams struct { UserinfoJson string } -func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error) { +func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, createOIDCSession, arg.Sub, arg.AccessTokenHash, @@ -50,7 +50,7 @@ func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionPa arg.Nonce, arg.UserinfoJson, ) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -110,9 +110,9 @@ type GetOIDCConsentByUsernameAndClientIDParams struct { ClientID string } -func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) { +func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OIDCConsent, error) { row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID) - var i OidcConsent + var i OIDCConsent err := row.Scan( &i.Username, &i.ClientID, @@ -127,9 +127,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "access_token_hash" = $1 ` -func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionByAccessTokenHash, accessTokenHash) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -149,9 +149,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "refresh_token_hash" = $1 ` -func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionByRefreshTokenHash, refreshTokenHash) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -171,9 +171,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "sub" = $1 ` -func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionBySub, sub) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -192,15 +192,15 @@ const listOIDCConsents = `-- name: ListOIDCConsents :many SELECT username, client_id, scope, created_at FROM "oidc_consents" ` -func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) { +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 + var items []OIDCConsent for rows.Next() { - var i OidcConsent + var i OIDCConsent if err := rows.Scan( &i.Username, &i.ClientID, @@ -246,7 +246,7 @@ type UpdateOIDCSessionParams struct { Sub string } -func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) { +func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, updateOIDCSession, arg.AccessTokenHash, arg.RefreshTokenHash, @@ -258,7 +258,7 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa arg.UserinfoJson, arg.Sub, ) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -296,14 +296,14 @@ type UpsertOIDCConsentParams struct { CreatedAt int64 } -func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) { +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 + var i OIDCConsent err := row.Scan( &i.Username, &i.ClientID, diff --git a/internal/repository/postgres/session.queries.sql.go b/internal/repository/postgres/session.queries.sql.go new file mode 100644 index 000000000..0c4761998 --- /dev/null +++ b/internal/repository/postgres/session.queries.sql.go @@ -0,0 +1,93 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: session.queries.sql + +package postgres + +import ( + "context" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO "sessions" ( + "uuid", + "identity_uuid", + "csrf_token", + "expires_at", + "created_at", + "context_json" +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json +` + +type CreateSessionParams struct { + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRowContext(ctx, createSession, + arg.UUID, + arg.IdentityUuid, + arg.CSRFToken, + arg.ExpiresAt, + arg.CreatedAt, + arg.ContextJSON, + ) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUuid, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM "sessions" +WHERE "expires_at" < $1 +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiresAt int64) error { + _, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiresAt) + return err +} + +const deleteSessionByUUID = `-- name: DeleteSessionByUUID :exec +DELETE FROM "sessions" +WHERE "uuid" = $1 +` + +func (q *Queries) DeleteSessionByUUID(ctx context.Context, uuid string) error { + _, err := q.db.ExecContext(ctx, deleteSessionByUUID, uuid) + return err +} + +const getSessionByUUID = `-- name: GetSessionByUUID :one +SELECT uuid, identity_uuid, csrf_token, expires_at, created_at, context_json FROM "sessions" +WHERE "uuid" = $1 +` + +func (q *Queries) GetSessionByUUID(ctx context.Context, uuid string) (Session, error) { + row := q.db.QueryRowContext(ctx, getSessionByUUID, uuid) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUuid, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} diff --git a/internal/repository/postgres/session_queries.sql.go b/internal/repository/postgres/session_queries.sql.go deleted file mode 100644 index c7ea71d48..000000000 --- a/internal/repository/postgres/session_queries.sql.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: session_queries.sql - -package postgres - -import ( - "context" -) - -const createSession = `-- name: CreateSession :one -INSERT INTO "sessions" ( - "uuid", - "username", - "email", - "name", - "provider", - "totp_pending", - "oauth_groups", - "expiry", - "created_at", - "oauth_name", - "oauth_sub" -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 -) -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub -` - -type CreateSessionParams struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string -} - -func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { - row := q.db.QueryRowContext(ctx, createSession, - arg.UUID, - arg.Username, - arg.Email, - arg.Name, - arg.Provider, - arg.TotpPending, - arg.OAuthGroups, - arg.Expiry, - arg.CreatedAt, - arg.OAuthName, - arg.OAuthSub, - ) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} - -const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec -DELETE FROM "sessions" -WHERE "expiry" < $1 -` - -func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error { - _, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry) - return err -} - -const deleteSession = `-- name: DeleteSession :exec -DELETE FROM "sessions" -WHERE "uuid" = $1 -` - -func (q *Queries) DeleteSession(ctx context.Context, uuid string) error { - _, err := q.db.ExecContext(ctx, deleteSession, uuid) - return err -} - -const getSession = `-- name: GetSession :one -SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions" -WHERE "uuid" = $1 -` - -func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) { - row := q.db.QueryRowContext(ctx, getSession, uuid) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} - -const updateSession = `-- name: UpdateSession :one -UPDATE "sessions" SET - "username" = $1, - "email" = $2, - "name" = $3, - "provider" = $4, - "totp_pending" = $5, - "oauth_groups" = $6, - "expiry" = $7, - "oauth_name" = $8, - "oauth_sub" = $9 -WHERE "uuid" = $10 -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub -` - -type UpdateSessionParams struct { - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - OAuthName string - OAuthSub string - UUID string -} - -func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) { - row := q.db.QueryRowContext(ctx, updateSession, - arg.Username, - arg.Email, - arg.Name, - arg.Provider, - arg.TotpPending, - arg.OAuthGroups, - arg.Expiry, - arg.OAuthName, - arg.OAuthSub, - arg.UUID, - ) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} diff --git a/internal/repository/postgres/store.go b/internal/repository/postgres/store.go index 1857016a2..0a68d35e5 100644 --- a/internal/repository/postgres/store.go +++ b/internal/repository/postgres/store.go @@ -32,12 +32,20 @@ func mapErr(err error) error { return err } -func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) { +func (s *Store) CreateIdentity(ctx context.Context, arg repository.CreateIdentityParams) (repository.Identity, error) { + r, err := s.q.CreateIdentity(ctx, CreateIdentityParams(arg)) + if err != nil { + return repository.Identity{}, mapErr(err) + } + return repository.Identity(r), nil +} + +func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OIDCSession, error) { r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg)) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) { @@ -48,12 +56,20 @@ func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionP return repository.Session(r), nil } +func (s *Store) CreateUser(ctx context.Context, arg repository.CreateUserParams) (repository.User, error) { + r, err := s.q.CreateUser(ctx, CreateUserParams(arg)) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) DeleteExpiredOIDCSessions(ctx context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error { return mapErr(s.q.DeleteExpiredOIDCSessions(ctx, DeleteExpiredOIDCSessionsParams(arg))) } -func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error { - return mapErr(s.q.DeleteExpiredSessions(ctx, expiry)) +func (s *Store) DeleteExpiredSessions(ctx context.Context, expiresAt int64) error { + return mapErr(s.q.DeleteExpiredSessions(ctx, expiresAt)) } func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error { @@ -64,82 +80,90 @@ func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error { return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub)) } -func (s *Store) DeleteSession(ctx context.Context, uuid string) error { - return mapErr(s.q.DeleteSession(ctx, uuid)) +func (s *Store) DeleteSessionByUUID(ctx context.Context, uuid string) error { + return mapErr(s.q.DeleteSessionByUUID(ctx, uuid)) +} + +func (s *Store) GetIdentityByUUID(ctx context.Context, uuid string) (repository.Identity, error) { + r, err := s.q.GetIdentityByUUID(ctx, uuid) + if err != nil { + return repository.Identity{}, mapErr(err) + } + return repository.Identity(r), nil } -func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) { +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{}, mapErr(err) } - return repository.OidcConsent(r), nil + 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) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OidcSession, error) { +func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OIDCSession, error) { r, err := s.q.GetOIDCSessionByRefreshTokenHash(ctx, refreshTokenHash) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OidcSession, error) { +func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OIDCSession, error) { r, err := s.q.GetOIDCSessionBySub(ctx, sub) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) { - r, err := s.q.GetSession(ctx, uuid) +func (s *Store) GetSessionByUUID(ctx context.Context, uuid string) (repository.Session, error) { + r, err := s.q.GetSessionByUUID(ctx, uuid) if err != nil { return repository.Session{}, mapErr(err) } return repository.Session(r), nil } -func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) { +func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User, error) { + r, err := s.q.GetUserByUUID(ctx, uuid) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(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)) + out := make([]repository.OIDCConsent, len(rows)) for i, row := range rows { - out[i] = repository.OidcConsent(row) + 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)) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil -} - -func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) { - r, err := s.q.UpdateSession(ctx, UpdateSessionParams(arg)) - if err != nil { - return repository.Session{}, mapErr(err) - } - return repository.Session(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) { +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{}, mapErr(err) } - return repository.OidcConsent(r), nil + return repository.OIDCConsent(r), nil } diff --git a/internal/repository/postgres/users.queries.sql.go b/internal/repository/postgres/users.queries.sql.go new file mode 100644 index 000000000..c6ad6f541 --- /dev/null +++ b/internal/repository/postgres/users.queries.sql.go @@ -0,0 +1,63 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.queries.sql + +package postgres + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO "users" ( + "uuid", + "username", + "email", + "name" +) VALUES ( + $1, $2, $3, $4 +) +RETURNING uuid, username, email, name +` + +type CreateUserParams struct { + UUID string + Username string + Email string + Name string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.UUID, + arg.Username, + arg.Email, + arg.Name, + ) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + +const getUserByUUID = `-- name: GetUserByUUID :one +SELECT uuid, username, email, name FROM "users" +WHERE "uuid" = $1 +` + +func (q *Queries) GetUserByUUID(ctx context.Context, uuid string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUUID, uuid) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} diff --git a/internal/repository/sqlite/identities.queries.sql.go b/internal/repository/sqlite/identities.queries.sql.go new file mode 100644 index 000000000..231772c70 --- /dev/null +++ b/internal/repository/sqlite/identities.queries.sql.go @@ -0,0 +1,68 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: identities.queries.sql + +package sqlite + +import ( + "context" +) + +const createIdentity = `-- name: CreateIdentity :one +INSERT INTO "identities" ( + "uuid", + "user_uuid", + "provider", + "provider_user_id", + "context_json" +) VALUES ( + ?, ?, ?, ?, ? +) +RETURNING uuid, user_uuid, provider, provider_user_id, context_json +` + +type CreateIdentityParams struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) { + row := q.db.QueryRowContext(ctx, createIdentity, + arg.UUID, + arg.UserUUID, + arg.Provider, + arg.ProviderUserID, + arg.ContextJSON, + ) + var i Identity + err := row.Scan( + &i.UUID, + &i.UserUUID, + &i.Provider, + &i.ProviderUserID, + &i.ContextJSON, + ) + return i, err +} + +const getIdentityByUUID = `-- name: GetIdentityByUUID :one +SELECT uuid, user_uuid, provider, provider_user_id, context_json FROM "identities" +WHERE "uuid" = ? +` + +func (q *Queries) GetIdentityByUUID(ctx context.Context, uuid string) (Identity, error) { + row := q.db.QueryRowContext(ctx, getIdentityByUUID, uuid) + var i Identity + err := row.Scan( + &i.UUID, + &i.UserUUID, + &i.Provider, + &i.ProviderUserID, + &i.ContextJSON, + ) + return i, err +} diff --git a/internal/repository/sqlite/models.go b/internal/repository/sqlite/models.go index f30ae6726..8c282dd83 100644 --- a/internal/repository/sqlite/models.go +++ b/internal/repository/sqlite/models.go @@ -4,14 +4,22 @@ package sqlite -type OidcConsent struct { +type Identity struct { + UUID string + UserUUID string + Provider string + ProviderUserID string + ContextJSON string +} + +type OIDCConsent struct { Username string ClientID string Scope string CreatedAt int64 } -type OidcSession struct { +type OIDCSession struct { Sub string AccessTokenHash string RefreshTokenHash string @@ -24,15 +32,17 @@ type OidcSession struct { } type Session struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string +} + +type User struct { + UUID string + Username string + Email string + Name string } diff --git a/internal/repository/sqlite/oidc_queries.sql.go b/internal/repository/sqlite/oidc.queries.sql.go similarity index 92% rename from internal/repository/sqlite/oidc_queries.sql.go rename to internal/repository/sqlite/oidc.queries.sql.go index e574197a8..7800f03c5 100644 --- a/internal/repository/sqlite/oidc_queries.sql.go +++ b/internal/repository/sqlite/oidc.queries.sql.go @@ -1,7 +1,7 @@ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.31.1 -// source: oidc_queries.sql +// source: oidc.queries.sql package sqlite @@ -38,7 +38,7 @@ type CreateOIDCSessionParams struct { UserinfoJson string } -func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error) { +func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, createOIDCSession, arg.Sub, arg.AccessTokenHash, @@ -50,7 +50,7 @@ func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionPa arg.Nonce, arg.UserinfoJson, ) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -110,9 +110,9 @@ type GetOIDCConsentByUsernameAndClientIDParams struct { ClientID string } -func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) { +func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OIDCConsent, error) { row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID) - var i OidcConsent + var i OIDCConsent err := row.Scan( &i.Username, &i.ClientID, @@ -127,9 +127,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "access_token_hash" = ? ` -func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionByAccessTokenHash, accessTokenHash) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -149,9 +149,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "refresh_token_hash" = ? ` -func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionByRefreshTokenHash, refreshTokenHash) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -171,9 +171,9 @@ SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expir WHERE "sub" = ? ` -func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) { +func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, getOIDCSessionBySub, sub) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -192,15 +192,15 @@ const listOIDCConsents = `-- name: ListOIDCConsents :many SELECT username, client_id, scope, created_at FROM "oidc_consents" ` -func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) { +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 + var items []OIDCConsent for rows.Next() { - var i OidcConsent + var i OIDCConsent if err := rows.Scan( &i.Username, &i.ClientID, @@ -246,7 +246,7 @@ type UpdateOIDCSessionParams struct { Sub string } -func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) { +func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OIDCSession, error) { row := q.db.QueryRowContext(ctx, updateOIDCSession, arg.AccessTokenHash, arg.RefreshTokenHash, @@ -258,7 +258,7 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa arg.UserinfoJson, arg.Sub, ) - var i OidcSession + var i OIDCSession err := row.Scan( &i.Sub, &i.AccessTokenHash, @@ -296,14 +296,14 @@ type UpsertOIDCConsentParams struct { CreatedAt int64 } -func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) { +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 + var i OIDCConsent err := row.Scan( &i.Username, &i.ClientID, diff --git a/internal/repository/sqlite/session.queries.sql.go b/internal/repository/sqlite/session.queries.sql.go new file mode 100644 index 000000000..0ef05f7ee --- /dev/null +++ b/internal/repository/sqlite/session.queries.sql.go @@ -0,0 +1,93 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: session.queries.sql + +package sqlite + +import ( + "context" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO "sessions" ( + "uuid", + "identity_uuid", + "csrf_token", + "expires_at", + "created_at", + "context_json" +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json +` + +type CreateSessionParams struct { + UUID string + IdentityUuid string + CSRFToken string + ExpiresAt int64 + CreatedAt int64 + ContextJSON string +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRowContext(ctx, createSession, + arg.UUID, + arg.IdentityUuid, + arg.CSRFToken, + arg.ExpiresAt, + arg.CreatedAt, + arg.ContextJSON, + ) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUuid, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM "sessions" +WHERE "expires_at" < ? +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiresAt int64) error { + _, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiresAt) + return err +} + +const deleteSessionByUUID = `-- name: DeleteSessionByUUID :exec +DELETE FROM "sessions" +WHERE "uuid" = ? +` + +func (q *Queries) DeleteSessionByUUID(ctx context.Context, uuid string) error { + _, err := q.db.ExecContext(ctx, deleteSessionByUUID, uuid) + return err +} + +const getSessionByUUID = `-- name: GetSessionByUUID :one +SELECT uuid, identity_uuid, csrf_token, expires_at, created_at, context_json FROM "sessions" +WHERE "uuid" = ? +` + +func (q *Queries) GetSessionByUUID(ctx context.Context, uuid string) (Session, error) { + row := q.db.QueryRowContext(ctx, getSessionByUUID, uuid) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUuid, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} diff --git a/internal/repository/sqlite/session_queries.sql.go b/internal/repository/sqlite/session_queries.sql.go deleted file mode 100644 index 7792fc4b2..000000000 --- a/internal/repository/sqlite/session_queries.sql.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: session_queries.sql - -package sqlite - -import ( - "context" -) - -const createSession = `-- name: CreateSession :one -INSERT INTO "sessions" ( - "uuid", - "username", - "email", - "name", - "provider", - "totp_pending", - "oauth_groups", - "expiry", - "created_at", - "oauth_name", - "oauth_sub" -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub -` - -type CreateSessionParams struct { - UUID string - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - CreatedAt int64 - OAuthName string - OAuthSub string -} - -func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { - row := q.db.QueryRowContext(ctx, createSession, - arg.UUID, - arg.Username, - arg.Email, - arg.Name, - arg.Provider, - arg.TotpPending, - arg.OAuthGroups, - arg.Expiry, - arg.CreatedAt, - arg.OAuthName, - arg.OAuthSub, - ) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} - -const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec -DELETE FROM "sessions" -WHERE "expiry" < ? -` - -func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error { - _, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry) - return err -} - -const deleteSession = `-- name: DeleteSession :exec -DELETE FROM "sessions" -WHERE "uuid" = ? -` - -func (q *Queries) DeleteSession(ctx context.Context, uuid string) error { - _, err := q.db.ExecContext(ctx, deleteSession, uuid) - return err -} - -const getSession = `-- name: GetSession :one -SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions" -WHERE "uuid" = ? -` - -func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) { - row := q.db.QueryRowContext(ctx, getSession, uuid) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} - -const updateSession = `-- name: UpdateSession :one -UPDATE "sessions" SET - "username" = ?, - "email" = ?, - "name" = ?, - "provider" = ?, - "totp_pending" = ?, - "oauth_groups" = ?, - "expiry" = ?, - "oauth_name" = ?, - "oauth_sub" = ? -WHERE "uuid" = ? -RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub -` - -type UpdateSessionParams struct { - Username string - Email string - Name string - Provider string - TotpPending bool - OAuthGroups string - Expiry int64 - OAuthName string - OAuthSub string - UUID string -} - -func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) { - row := q.db.QueryRowContext(ctx, updateSession, - arg.Username, - arg.Email, - arg.Name, - arg.Provider, - arg.TotpPending, - arg.OAuthGroups, - arg.Expiry, - arg.OAuthName, - arg.OAuthSub, - arg.UUID, - ) - var i Session - err := row.Scan( - &i.UUID, - &i.Username, - &i.Email, - &i.Name, - &i.Provider, - &i.TotpPending, - &i.OAuthGroups, - &i.Expiry, - &i.CreatedAt, - &i.OAuthName, - &i.OAuthSub, - ) - return i, err -} diff --git a/internal/repository/sqlite/store.go b/internal/repository/sqlite/store.go index 3c0d8a78c..80eae5a66 100644 --- a/internal/repository/sqlite/store.go +++ b/internal/repository/sqlite/store.go @@ -32,12 +32,20 @@ func mapErr(err error) error { return err } -func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) { +func (s *Store) CreateIdentity(ctx context.Context, arg repository.CreateIdentityParams) (repository.Identity, error) { + r, err := s.q.CreateIdentity(ctx, CreateIdentityParams(arg)) + if err != nil { + return repository.Identity{}, mapErr(err) + } + return repository.Identity(r), nil +} + +func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OIDCSession, error) { r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg)) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) { @@ -48,12 +56,20 @@ func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionP return repository.Session(r), nil } +func (s *Store) CreateUser(ctx context.Context, arg repository.CreateUserParams) (repository.User, error) { + r, err := s.q.CreateUser(ctx, CreateUserParams(arg)) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) DeleteExpiredOIDCSessions(ctx context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error { return mapErr(s.q.DeleteExpiredOIDCSessions(ctx, DeleteExpiredOIDCSessionsParams(arg))) } -func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error { - return mapErr(s.q.DeleteExpiredSessions(ctx, expiry)) +func (s *Store) DeleteExpiredSessions(ctx context.Context, expiresAt int64) error { + return mapErr(s.q.DeleteExpiredSessions(ctx, expiresAt)) } func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error { @@ -64,82 +80,90 @@ func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error { return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub)) } -func (s *Store) DeleteSession(ctx context.Context, uuid string) error { - return mapErr(s.q.DeleteSession(ctx, uuid)) +func (s *Store) DeleteSessionByUUID(ctx context.Context, uuid string) error { + return mapErr(s.q.DeleteSessionByUUID(ctx, uuid)) +} + +func (s *Store) GetIdentityByUUID(ctx context.Context, uuid string) (repository.Identity, error) { + r, err := s.q.GetIdentityByUUID(ctx, uuid) + if err != nil { + return repository.Identity{}, mapErr(err) + } + return repository.Identity(r), nil } -func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) { +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{}, mapErr(err) } - return repository.OidcConsent(r), nil + 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) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OidcSession, error) { +func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OIDCSession, error) { r, err := s.q.GetOIDCSessionByRefreshTokenHash(ctx, refreshTokenHash) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OidcSession, error) { +func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OIDCSession, error) { r, err := s.q.GetOIDCSessionBySub(ctx, sub) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) { - r, err := s.q.GetSession(ctx, uuid) +func (s *Store) GetSessionByUUID(ctx context.Context, uuid string) (repository.Session, error) { + r, err := s.q.GetSessionByUUID(ctx, uuid) if err != nil { return repository.Session{}, mapErr(err) } return repository.Session(r), nil } -func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) { +func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User, error) { + r, err := s.q.GetUserByUUID(ctx, uuid) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(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)) + out := make([]repository.OIDCConsent, len(rows)) for i, row := range rows { - out[i] = repository.OidcConsent(row) + 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)) if err != nil { - return repository.OidcSession{}, mapErr(err) + return repository.OIDCSession{}, mapErr(err) } - return repository.OidcSession(r), nil -} - -func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) { - r, err := s.q.UpdateSession(ctx, UpdateSessionParams(arg)) - if err != nil { - return repository.Session{}, mapErr(err) - } - return repository.Session(r), nil + return repository.OIDCSession(r), nil } -func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) { +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{}, mapErr(err) } - return repository.OidcConsent(r), nil + return repository.OIDCConsent(r), nil } diff --git a/internal/repository/sqlite/users.queries.sql.go b/internal/repository/sqlite/users.queries.sql.go new file mode 100644 index 000000000..a3f251eb3 --- /dev/null +++ b/internal/repository/sqlite/users.queries.sql.go @@ -0,0 +1,63 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.queries.sql + +package sqlite + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO "users" ( + "uuid", + "username", + "email", + "name" +) VALUES ( + ?, ?, ?, ? +) +RETURNING uuid, username, email, name +` + +type CreateUserParams struct { + UUID string + Username string + Email string + Name string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.UUID, + arg.Username, + arg.Email, + arg.Name, + ) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + +const getUserByUUID = `-- name: GetUserByUUID :one +SELECT uuid, username, email, name FROM "users" +WHERE "uuid" = ? +` + +func (q *Queries) GetUserByUUID(ctx context.Context, uuid string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUUID, uuid) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} diff --git a/internal/repository/store.go b/internal/repository/store.go index 4291cd468..0ec2a2005 100644 --- a/internal/repository/store.go +++ b/internal/repository/store.go @@ -12,25 +12,31 @@ var ErrNotFound = errors.New("not found") // The sqlc-generated *Queries struct satisfies this interface for SQLite. // Future drivers (postgres, etc.) must return the shared types defined in this package. type Store interface { + // Users + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + GetUserByUUID(ctx context.Context, uuid string) (User, error) + + // Identities + CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) + GetIdentityByUUID(ctx context.Context, uuid string) (Identity, error) + // Sessions CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) - GetSession(ctx context.Context, uuid string) (Session, error) - UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) - DeleteSession(ctx context.Context, uuid string) error - DeleteExpiredSessions(ctx context.Context, expiry int64) error + DeleteExpiredSessions(ctx context.Context, expiresAt int64) error + DeleteSessionByUUID(ctx context.Context, uuid string) error + GetSessionByUUID(ctx context.Context, uuid string) (Session, error) - // OIDC sessions - CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error) + // OIDC + CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OIDCSession, error) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpiredOIDCSessionsParams) error DeleteOIDCSessionBySub(ctx context.Context, sub string) error - GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error) - GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) - GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) - UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) + GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OIDCSession, error) + GetOIDCSessionBySub(ctx context.Context, sub string) (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) + // OIDC Consent DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error - ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) + GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OIDCConsent, error) + ListOIDCConsents(ctx context.Context) ([]OIDCConsent, error) + UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OIDCConsent, error) } diff --git a/sql/postgres/identities.queries.sql b/sql/postgres/identities.queries.sql new file mode 100644 index 000000000..2f74e2968 --- /dev/null +++ b/sql/postgres/identities.queries.sql @@ -0,0 +1,15 @@ +-- name: CreateIdentity :one +INSERT INTO "identities" ( + "uuid", + "user_uuid", + "provider", + "provider_user_id", + "context_json" +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING *; + +-- name: GetIdentityByUUID :one +SELECT * FROM "identities" +WHERE "uuid" = $1; diff --git a/sql/postgres/identities.schemas.sql b/sql/postgres/identities.schemas.sql new file mode 100644 index 000000000..36a60b121 --- /dev/null +++ b/sql/postgres/identities.schemas.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "identities" ( + "uuid" TEXT PRIMARY KEY, + "user_uuid" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_user_id" TEXT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("user_uuid") REFERENCES "users"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/postgres/oidc_queries.sql b/sql/postgres/oidc.queries.sql similarity index 100% rename from sql/postgres/oidc_queries.sql rename to sql/postgres/oidc.queries.sql diff --git a/sql/postgres/oidc_schemas.sql b/sql/postgres/oidc.schemas.sql similarity index 100% rename from sql/postgres/oidc_schemas.sql rename to sql/postgres/oidc.schemas.sql diff --git a/sql/postgres/session.queries.sql b/sql/postgres/session.queries.sql new file mode 100644 index 000000000..1fbf7536d --- /dev/null +++ b/sql/postgres/session.queries.sql @@ -0,0 +1,24 @@ +-- name: CreateSession :one +INSERT INTO "sessions" ( + "uuid", + "identity_uuid", + "csrf_token", + "expires_at", + "created_at", + "context_json" +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING *; + +-- name: GetSessionByUUID :one +SELECT * FROM "sessions" +WHERE "uuid" = $1; + +-- name: DeleteSessionByUUID :exec +DELETE FROM "sessions" +WHERE "uuid" = $1; + +-- name: DeleteExpiredSessions :exec +DELETE FROM "sessions" +WHERE "expires_at" < $1; diff --git a/sql/postgres/session.schemas.sql b/sql/postgres/session.schemas.sql new file mode 100644 index 000000000..df4caa3af --- /dev/null +++ b/sql/postgres/session.schemas.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "sessions" ( + "uuid" TEXT PRIMARY KEY, + "identity_uuid" TEXT NOT NULL, + "csrf_token" TEXT NOT NULL, + "expires_at" BIGINT NOT NULL, + "created_at" BIGINT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("identity_uuid") REFERENCES "identities"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/postgres/session_queries.sql b/sql/postgres/session_queries.sql deleted file mode 100644 index 22aecd46f..000000000 --- a/sql/postgres/session_queries.sql +++ /dev/null @@ -1,43 +0,0 @@ --- name: CreateSession :one -INSERT INTO "sessions" ( - "uuid", - "username", - "email", - "name", - "provider", - "totp_pending", - "oauth_groups", - "expiry", - "created_at", - "oauth_name", - "oauth_sub" -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 -) -RETURNING *; - --- name: GetSession :one -SELECT * FROM "sessions" -WHERE "uuid" = $1; - --- name: DeleteSession :exec -DELETE FROM "sessions" -WHERE "uuid" = $1; - --- name: UpdateSession :one -UPDATE "sessions" SET - "username" = $1, - "email" = $2, - "name" = $3, - "provider" = $4, - "totp_pending" = $5, - "oauth_groups" = $6, - "expiry" = $7, - "oauth_name" = $8, - "oauth_sub" = $9 -WHERE "uuid" = $10 -RETURNING *; - --- name: DeleteExpiredSessions :exec -DELETE FROM "sessions" -WHERE "expiry" < $1; diff --git a/sql/postgres/users.queries.sql b/sql/postgres/users.queries.sql new file mode 100644 index 000000000..10812470f --- /dev/null +++ b/sql/postgres/users.queries.sql @@ -0,0 +1,14 @@ +-- name: CreateUser :one +INSERT INTO "users" ( + "uuid", + "username", + "email", + "name" +) VALUES ( + $1, $2, $3, $4 +) +RETURNING *; + +-- name: GetUserByUUID :one +SELECT * FROM "users" +WHERE "uuid" = $1; diff --git a/sql/postgres/users.schemas.sql b/sql/postgres/users.schemas.sql new file mode 100644 index 000000000..0521cfe5d --- /dev/null +++ b/sql/postgres/users.schemas.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "users" ( + "uuid" TEXT PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL +); \ No newline at end of file diff --git a/sql/sqlite/identities.queries.sql b/sql/sqlite/identities.queries.sql new file mode 100644 index 000000000..79743369b --- /dev/null +++ b/sql/sqlite/identities.queries.sql @@ -0,0 +1,15 @@ +-- name: CreateIdentity :one +INSERT INTO "identities" ( + "uuid", + "user_uuid", + "provider", + "provider_user_id", + "context_json" +) VALUES ( + ?, ?, ?, ?, ? +) +RETURNING *; + +-- name: GetIdentityByUUID :one +SELECT * FROM "identities" +WHERE "uuid" = ?; diff --git a/sql/sqlite/identities.schemas.sql b/sql/sqlite/identities.schemas.sql new file mode 100644 index 000000000..36a60b121 --- /dev/null +++ b/sql/sqlite/identities.schemas.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "identities" ( + "uuid" TEXT PRIMARY KEY, + "user_uuid" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_user_id" TEXT NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("user_uuid") REFERENCES "users"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/sqlite/oidc_queries.sql b/sql/sqlite/oidc.queries.sql similarity index 100% rename from sql/sqlite/oidc_queries.sql rename to sql/sqlite/oidc.queries.sql diff --git a/sql/sqlite/oidc_schemas.sql b/sql/sqlite/oidc.schemas.sql similarity index 100% rename from sql/sqlite/oidc_schemas.sql rename to sql/sqlite/oidc.schemas.sql diff --git a/sql/sqlite/session.queries.sql b/sql/sqlite/session.queries.sql new file mode 100644 index 000000000..b923ca323 --- /dev/null +++ b/sql/sqlite/session.queries.sql @@ -0,0 +1,24 @@ +-- name: CreateSession :one +INSERT INTO "sessions" ( + "uuid", + "identity_uuid", + "csrf_token", + "expires_at", + "created_at", + "context_json" +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING *; + +-- name: GetSessionByUUID :one +SELECT * FROM "sessions" +WHERE "uuid" = ?; + +-- name: DeleteSessionByUUID :exec +DELETE FROM "sessions" +WHERE "uuid" = ?; + +-- name: DeleteExpiredSessions :exec +DELETE FROM "sessions" +WHERE "expires_at" < ?; diff --git a/sql/sqlite/session.schemas.sql b/sql/sqlite/session.schemas.sql new file mode 100644 index 000000000..acc05d447 --- /dev/null +++ b/sql/sqlite/session.schemas.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "sessions" ( + "uuid" TEXT PRIMARY KEY, + "identity_uuid" TEXT NOT NULL, + "csrf_token" TEXT NOT NULL, + "expires_at" INTEGER NOT NULL, + "created_at" INTEGER NOT NULL, + "context_json" TEXT NOT NULL, + FOREIGN KEY("identity_uuid") REFERENCES "identities"("uuid") ON DELETE CASCADE +); \ No newline at end of file diff --git a/sql/sqlite/session_queries.sql b/sql/sqlite/session_queries.sql deleted file mode 100644 index da93126e3..000000000 --- a/sql/sqlite/session_queries.sql +++ /dev/null @@ -1,43 +0,0 @@ --- name: CreateSession :one -INSERT INTO "sessions" ( - "uuid", - "username", - "email", - "name", - "provider", - "totp_pending", - "oauth_groups", - "expiry", - "created_at", - "oauth_name", - "oauth_sub" -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) -RETURNING *; - --- name: GetSession :one -SELECT * FROM "sessions" -WHERE "uuid" = ?; - --- name: DeleteSession :exec -DELETE FROM "sessions" -WHERE "uuid" = ?; - --- name: UpdateSession :one -UPDATE "sessions" SET - "username" = ?, - "email" = ?, - "name" = ?, - "provider" = ?, - "totp_pending" = ?, - "oauth_groups" = ?, - "expiry" = ?, - "oauth_name" = ?, - "oauth_sub" = ? -WHERE "uuid" = ? -RETURNING *; - --- name: DeleteExpiredSessions :exec -DELETE FROM "sessions" -WHERE "expiry" < ?; diff --git a/sql/sqlite/users.queries.sql b/sql/sqlite/users.queries.sql new file mode 100644 index 000000000..155b93024 --- /dev/null +++ b/sql/sqlite/users.queries.sql @@ -0,0 +1,14 @@ +-- name: CreateUser :one +INSERT INTO "users" ( + "uuid", + "username", + "email", + "name" +) VALUES ( + ?, ?, ?, ? +) +RETURNING *; + +-- name: GetUserByUUID :one +SELECT * FROM "users" +WHERE "uuid" = ?; diff --git a/sql/sqlite/users.schemas.sql b/sql/sqlite/users.schemas.sql new file mode 100644 index 000000000..0521cfe5d --- /dev/null +++ b/sql/sqlite/users.schemas.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "users" ( + "uuid" TEXT PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL +); \ No newline at end of file diff --git a/sqlc.yml b/sqlc.yml index e4f98a259..f9b49af25 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -1,39 +1,35 @@ version: "2" sql: - engine: "sqlite" - queries: "sql/sqlite/*_queries.sql" - schema: "sql/sqlite/*_schemas.sql" + queries: "sql/sqlite/*.queries.sql" + schema: "sql/sqlite/*.schemas.sql" gen: go: package: "sqlite" out: "internal/repository/sqlite" rename: uuid: "UUID" - oauth_groups: "OAuthGroups" - oauth_name: "OAuthName" - oauth_sub: "OAuthSub" redirect_uri: "RedirectURI" + user_uuid: "UserUUID" + context_json: "ContextJSON" + csrf_token: "CSRFToken" + oidc_session: "OIDCSession" + oidc_consent: "OIDCConsent" overrides: - - column: "sessions.oauth_groups" - go_type: "string" - - column: "sessions.oauth_name" - go_type: "string" - - column: "sessions.oauth_sub" - go_type: "string" - - column: "sessions.ldap_groups" - go_type: "string" - column: "oidc_sessions.nonce" go_type: "string" - engine: "postgresql" - queries: "sql/postgres/*_queries.sql" - schema: "sql/postgres/*_schemas.sql" + queries: "sql/postgres/*.queries.sql" + schema: "sql/postgres/*.schemas.sql" gen: go: package: "postgres" out: "internal/repository/postgres" rename: uuid: "UUID" - oauth_groups: "OAuthGroups" - oauth_name: "OAuthName" - oauth_sub: "OAuthSub" redirect_uri: "RedirectURI" + user_uuid: "UserUUID" + context_json: "ContextJSON" + csrf_token: "CSRFToken" + oidc_session: "OIDCSession" + oidc_consent: "OIDCConsent" \ No newline at end of file From 2abcb180dfb0637ca271e61a8752fdfb42a50797 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 6 Nov 2026 19:37:10 +0200 Subject: [PATCH 02/16] wip: add new services for identity, sessions and oauth --- cmd/tinyauth/create_oidc_client.go | 2 +- go.mod | 2 +- internal/bootstrap/app_bootstrap.go | 19 +- internal/bootstrap/service_bootstrap.go | 6 + internal/model/context.go | 34 ++ internal/repository/models.go | 17 +- internal/repository/postgres/models.go | 2 +- .../postgres/session.queries.sql.go | 36 +- internal/repository/postgres/store.go | 28 ++ .../repository/postgres/users.queries.sql.go | 59 +++ internal/repository/sqlite/models.go | 2 +- .../repository/sqlite/session.queries.sql.go | 36 +- internal/repository/sqlite/store.go | 28 ++ .../repository/sqlite/users.queries.sql.go | 59 +++ internal/repository/store.go | 5 + internal/service/auth_service.go | 460 ++++-------------- internal/service/identity_service.go | 271 +++++++++++ internal/service/oauth_broker_service.go | 132 +++++ internal/service/oidc_service.go | 24 +- internal/service/sessions_service.go | 201 ++++++++ internal/utils/security_utils.go | 2 +- sql/postgres/session.queries.sql | 7 + sql/postgres/users.queries.sql | 15 + sql/sqlite/session.queries.sql | 7 + sql/sqlite/users.queries.sql | 15 + sqlc.yml | 4 +- 26 files changed, 1078 insertions(+), 395 deletions(-) create mode 100644 internal/service/identity_service.go create mode 100644 internal/service/sessions_service.go diff --git a/cmd/tinyauth/create_oidc_client.go b/cmd/tinyauth/create_oidc_client.go index 2ab4f084a..4952d7313 100644 --- a/cmd/tinyauth/create_oidc_client.go +++ b/cmd/tinyauth/create_oidc_client.go @@ -34,7 +34,7 @@ func createOidcClientCmd() *cli.Command { u := uuid.New() clientId := u.String() - clientSecret := "ta-" + utils.GenerateString(61) + clientSecret := "ta-" + utils.GenerateSecureString(61) uclientName := strings.ToUpper(clientName) lclientName := strings.ToLower(clientName) diff --git a/go.mod b/go.mod index 7de9fbd36..a66e891b6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/tinyauthapp/tinyauth -go 1.26.4 +go 1.27.3 require ( charm.land/huh/v2 v2.0.3 diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index a6fed0529..eca79ebb6 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -38,12 +38,14 @@ type Services struct { accessControlService *service.AccessControlsService authService *service.AuthService dockerService *service.DockerService + identityService *service.IdentityService kubernetesService *service.KubernetesService ldapService *service.LdapService oauthBrokerService *service.OAuthBrokerService oidcService *service.OIDCService tailscaleService *service.TailscaleService policyEngine *service.PolicyEngine + sessionsService *service.SessionsService } type BootstrapApp struct { @@ -253,17 +255,22 @@ func (app *BootstrapApp) Setup() error { if app.services.authService.LocalAuthConfigured() { configuredProviders = append(configuredProviders, model.Provider{ - Name: "Local", - ID: "local", - OAuth: false, + Name: "Local", + ID: "local", }) } if app.services.authService.LDAPAuthConfigured() { configuredProviders = append(configuredProviders, model.Provider{ - Name: "LDAP", - ID: "ldap", - OAuth: false, + Name: "LDAP", + ID: "ldap", + }) + } + + if app.services.authService.TailscaleAuthConfigured() { + configuredProviders = append(configuredProviders, model.Provider{ + Name: "Tailscale", + ID: "tailscale", }) } diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 0e61c5fb1..c31397ae9 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -31,6 +31,8 @@ func (app *BootstrapApp) setupServices() error { service.NewOAuthBrokerService, service.NewAuthService, service.NewOIDCService, + service.NewSessionsService, + service.NewIdentityService, } for _, provider := range serviceProvideFor { @@ -50,6 +52,8 @@ func (app *BootstrapApp) setupServices() error { OAuthBrokerService *service.OAuthBrokerService OIDCService *service.OIDCService TailscaleService *service.TailscaleService + SessionsService *service.SessionsService + IdentityService *service.IdentityService } err = app.dig.Invoke(func(i svcInput) error { @@ -59,6 +63,8 @@ func (app *BootstrapApp) setupServices() error { app.services.oauthBrokerService = i.OAuthBrokerService app.services.oidcService = i.OIDCService app.services.tailscaleService = i.TailscaleService + app.services.sessionsService = i.SessionsService + app.services.identityService = i.IdentityService return nil }) diff --git a/internal/model/context.go b/internal/model/context.go index 03d769419..f95d715af 100644 --- a/internal/model/context.go +++ b/internal/model/context.go @@ -22,6 +22,40 @@ const ( ProviderTailscale ) +func (t ProviderType) String() string { + switch t { + case ProviderLocal: + return "local" + case ProviderBasicAuth: + return "basic" + case ProviderOAuth: + return "oauth" + case ProviderLDAP: + return "ldap" + case ProviderTailscale: + return "tailscale" + default: + return "unknown" + } +} + +func ProviderFromString(s string) (ProviderType, error) { + switch s { + case "local": + return ProviderLocal, nil + case "basic": + return ProviderBasicAuth, nil + case "oauth": + return ProviderOAuth, nil + case "ldap": + return ProviderLDAP, nil + case "tailscale": + return ProviderTailscale, nil + default: + return ProviderLocal, errors.New("unknown provider") + } +} + type UserContext struct { Authenticated bool Provider ProviderType diff --git a/internal/repository/models.go b/internal/repository/models.go index 635a13c1d..de2419603 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -19,6 +19,13 @@ type CreateUserParams struct { Name string } +type UpdateUserParams struct { + Username string + Email string + Name string + UUID string +} + // Identities type Identity struct { @@ -41,7 +48,7 @@ type CreateIdentityParams struct { type Session struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 @@ -50,13 +57,19 @@ type Session struct { type CreateSessionParams struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 ContextJSON string } +type UpdateSessionByUUIDParams struct { + ExpiresAt int64 + ContextJSON string + UUID string +} + // OIDC type OIDCSession struct { diff --git a/internal/repository/postgres/models.go b/internal/repository/postgres/models.go index 4fa6f0103..e9eeb702d 100644 --- a/internal/repository/postgres/models.go +++ b/internal/repository/postgres/models.go @@ -33,7 +33,7 @@ type OIDCSession struct { type Session struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 diff --git a/internal/repository/postgres/session.queries.sql.go b/internal/repository/postgres/session.queries.sql.go index 0c4761998..2b0e0e9a8 100644 --- a/internal/repository/postgres/session.queries.sql.go +++ b/internal/repository/postgres/session.queries.sql.go @@ -25,7 +25,7 @@ RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json type CreateSessionParams struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 @@ -35,7 +35,7 @@ type CreateSessionParams struct { func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { row := q.db.QueryRowContext(ctx, createSession, arg.UUID, - arg.IdentityUuid, + arg.IdentityUUID, arg.CSRFToken, arg.ExpiresAt, arg.CreatedAt, @@ -44,7 +44,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S var i Session err := row.Scan( &i.UUID, - &i.IdentityUuid, + &i.IdentityUUID, &i.CSRFToken, &i.ExpiresAt, &i.CreatedAt, @@ -83,7 +83,35 @@ func (q *Queries) GetSessionByUUID(ctx context.Context, uuid string) (Session, e var i Session err := row.Scan( &i.UUID, - &i.IdentityUuid, + &i.IdentityUUID, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} + +const updateSessionByUUID = `-- name: UpdateSessionByUUID :one +UPDATE "sessions" SET + "expires_at" = $1, + "context_json" = $2 +WHERE "uuid" = $3 +RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json +` + +type UpdateSessionByUUIDParams struct { + ExpiresAt int64 + ContextJSON string + UUID string +} + +func (q *Queries) UpdateSessionByUUID(ctx context.Context, arg UpdateSessionByUUIDParams) (Session, error) { + row := q.db.QueryRowContext(ctx, updateSessionByUUID, arg.ExpiresAt, arg.ContextJSON, arg.UUID) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUUID, &i.CSRFToken, &i.ExpiresAt, &i.CreatedAt, diff --git a/internal/repository/postgres/store.go b/internal/repository/postgres/store.go index 0a68d35e5..419b6d7a7 100644 --- a/internal/repository/postgres/store.go +++ b/internal/repository/postgres/store.go @@ -132,6 +132,14 @@ func (s *Store) GetSessionByUUID(ctx context.Context, uuid string) (repository.S return repository.Session(r), nil } +func (s *Store) GetUserByEmail(ctx context.Context, email string) (repository.User, error) { + r, err := s.q.GetUserByEmail(ctx, email) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User, error) { r, err := s.q.GetUserByUUID(ctx, uuid) if err != nil { @@ -140,6 +148,14 @@ func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User return repository.User(r), nil } +func (s *Store) GetUserByUsername(ctx context.Context, username string) (repository.User, error) { + r, err := s.q.GetUserByUsername(ctx, username) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OIDCConsent, error) { rows, err := s.q.ListOIDCConsents(ctx) if err != nil { @@ -160,6 +176,18 @@ func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDC return repository.OIDCSession(r), nil } +func (s *Store) UpdateSessionByUUID(ctx context.Context, arg repository.UpdateSessionByUUIDParams) (repository.Session, error) { + r, err := s.q.UpdateSessionByUUID(ctx, UpdateSessionByUUIDParams(arg)) + if err != nil { + return repository.Session{}, mapErr(err) + } + return repository.Session(r), nil +} + +func (s *Store) UpdateUser(ctx context.Context, arg repository.UpdateUserParams) error { + return mapErr(s.q.UpdateUser(ctx, UpdateUserParams(arg))) +} + func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OIDCConsent, error) { r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg)) if err != nil { diff --git a/internal/repository/postgres/users.queries.sql.go b/internal/repository/postgres/users.queries.sql.go index c6ad6f541..3c6000736 100644 --- a/internal/repository/postgres/users.queries.sql.go +++ b/internal/repository/postgres/users.queries.sql.go @@ -45,6 +45,23 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e return i, err } +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT uuid, username, email, name FROM "users" +WHERE "email" = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + const getUserByUUID = `-- name: GetUserByUUID :one SELECT uuid, username, email, name FROM "users" WHERE "uuid" = $1 @@ -61,3 +78,45 @@ func (q *Queries) GetUserByUUID(ctx context.Context, uuid string) (User, error) ) return i, err } + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT uuid, username, email, name FROM "users" +WHERE "username" = $1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :exec +UPDATE "users" SET + "username" = $1, + "email" = $2, + "name" = $3 +WHERE "uuid" = $4 +` + +type UpdateUserParams struct { + Username string + Email string + Name string + UUID string +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { + _, err := q.db.ExecContext(ctx, updateUser, + arg.Username, + arg.Email, + arg.Name, + arg.UUID, + ) + return err +} diff --git a/internal/repository/sqlite/models.go b/internal/repository/sqlite/models.go index 8c282dd83..0e79217ed 100644 --- a/internal/repository/sqlite/models.go +++ b/internal/repository/sqlite/models.go @@ -33,7 +33,7 @@ type OIDCSession struct { type Session struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 diff --git a/internal/repository/sqlite/session.queries.sql.go b/internal/repository/sqlite/session.queries.sql.go index 0ef05f7ee..898f691ac 100644 --- a/internal/repository/sqlite/session.queries.sql.go +++ b/internal/repository/sqlite/session.queries.sql.go @@ -25,7 +25,7 @@ RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json type CreateSessionParams struct { UUID string - IdentityUuid string + IdentityUUID string CSRFToken string ExpiresAt int64 CreatedAt int64 @@ -35,7 +35,7 @@ type CreateSessionParams struct { func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { row := q.db.QueryRowContext(ctx, createSession, arg.UUID, - arg.IdentityUuid, + arg.IdentityUUID, arg.CSRFToken, arg.ExpiresAt, arg.CreatedAt, @@ -44,7 +44,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S var i Session err := row.Scan( &i.UUID, - &i.IdentityUuid, + &i.IdentityUUID, &i.CSRFToken, &i.ExpiresAt, &i.CreatedAt, @@ -83,7 +83,35 @@ func (q *Queries) GetSessionByUUID(ctx context.Context, uuid string) (Session, e var i Session err := row.Scan( &i.UUID, - &i.IdentityUuid, + &i.IdentityUUID, + &i.CSRFToken, + &i.ExpiresAt, + &i.CreatedAt, + &i.ContextJSON, + ) + return i, err +} + +const updateSessionByUUID = `-- name: UpdateSessionByUUID :one +UPDATE "sessions" SET + "expires_at" = ?, + "context_json" = ? +WHERE "uuid" = ? +RETURNING uuid, identity_uuid, csrf_token, expires_at, created_at, context_json +` + +type UpdateSessionByUUIDParams struct { + ExpiresAt int64 + ContextJSON string + UUID string +} + +func (q *Queries) UpdateSessionByUUID(ctx context.Context, arg UpdateSessionByUUIDParams) (Session, error) { + row := q.db.QueryRowContext(ctx, updateSessionByUUID, arg.ExpiresAt, arg.ContextJSON, arg.UUID) + var i Session + err := row.Scan( + &i.UUID, + &i.IdentityUUID, &i.CSRFToken, &i.ExpiresAt, &i.CreatedAt, diff --git a/internal/repository/sqlite/store.go b/internal/repository/sqlite/store.go index 80eae5a66..41089cc25 100644 --- a/internal/repository/sqlite/store.go +++ b/internal/repository/sqlite/store.go @@ -132,6 +132,14 @@ func (s *Store) GetSessionByUUID(ctx context.Context, uuid string) (repository.S return repository.Session(r), nil } +func (s *Store) GetUserByEmail(ctx context.Context, email string) (repository.User, error) { + r, err := s.q.GetUserByEmail(ctx, email) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User, error) { r, err := s.q.GetUserByUUID(ctx, uuid) if err != nil { @@ -140,6 +148,14 @@ func (s *Store) GetUserByUUID(ctx context.Context, uuid string) (repository.User return repository.User(r), nil } +func (s *Store) GetUserByUsername(ctx context.Context, username string) (repository.User, error) { + r, err := s.q.GetUserByUsername(ctx, username) + if err != nil { + return repository.User{}, mapErr(err) + } + return repository.User(r), nil +} + func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OIDCConsent, error) { rows, err := s.q.ListOIDCConsents(ctx) if err != nil { @@ -160,6 +176,18 @@ func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDC return repository.OIDCSession(r), nil } +func (s *Store) UpdateSessionByUUID(ctx context.Context, arg repository.UpdateSessionByUUIDParams) (repository.Session, error) { + r, err := s.q.UpdateSessionByUUID(ctx, UpdateSessionByUUIDParams(arg)) + if err != nil { + return repository.Session{}, mapErr(err) + } + return repository.Session(r), nil +} + +func (s *Store) UpdateUser(ctx context.Context, arg repository.UpdateUserParams) error { + return mapErr(s.q.UpdateUser(ctx, UpdateUserParams(arg))) +} + func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OIDCConsent, error) { r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg)) if err != nil { diff --git a/internal/repository/sqlite/users.queries.sql.go b/internal/repository/sqlite/users.queries.sql.go index a3f251eb3..70dc95f48 100644 --- a/internal/repository/sqlite/users.queries.sql.go +++ b/internal/repository/sqlite/users.queries.sql.go @@ -45,6 +45,23 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e return i, err } +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT uuid, username, email, name FROM "users" +WHERE "email" = ? +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + const getUserByUUID = `-- name: GetUserByUUID :one SELECT uuid, username, email, name FROM "users" WHERE "uuid" = ? @@ -61,3 +78,45 @@ func (q *Queries) GetUserByUUID(ctx context.Context, uuid string) (User, error) ) return i, err } + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT uuid, username, email, name FROM "users" +WHERE "username" = ? +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.UUID, + &i.Username, + &i.Email, + &i.Name, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :exec +UPDATE "users" SET + "username" = ?, + "email" = ?, + "name" = ? +WHERE "uuid" = ? +` + +type UpdateUserParams struct { + Username string + Email string + Name string + UUID string +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { + _, err := q.db.ExecContext(ctx, updateUser, + arg.Username, + arg.Email, + arg.Name, + arg.UUID, + ) + return err +} diff --git a/internal/repository/store.go b/internal/repository/store.go index 0ec2a2005..ec4ad3178 100644 --- a/internal/repository/store.go +++ b/internal/repository/store.go @@ -15,6 +15,9 @@ type Store interface { // Users CreateUser(ctx context.Context, arg CreateUserParams) (User, error) GetUserByUUID(ctx context.Context, uuid string) (User, error) + GetUserByUsername(ctx context.Context, username string) (User, error) + GetUserByEmail(ctx context.Context, email string) (User, error) + UpdateUser(ctx context.Context, arg UpdateUserParams) error // Identities CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) @@ -25,11 +28,13 @@ type Store interface { DeleteExpiredSessions(ctx context.Context, expiresAt int64) error DeleteSessionByUUID(ctx context.Context, uuid string) error GetSessionByUUID(ctx context.Context, uuid string) (Session, error) + UpdateSessionByUUID(ctx context.Context, arg UpdateSessionByUUIDParams) (Session, error) // OIDC CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OIDCSession, error) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpiredOIDCSessionsParams) error DeleteOIDCSessionBySub(ctx context.Context, sub string) error + GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OIDCSession, error) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OIDCSession, error) GetOIDCSessionBySub(ctx context.Context, sub string) (OIDCSession, error) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OIDCSession, error) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 5dd7e0b6e..7ba01aa1a 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -18,39 +18,13 @@ import ( "github.com/tinyauthapp/tinyauth/pkg/cache" "go.uber.org/dig" - "github.com/google/uuid" "golang.org/x/crypto/bcrypt" - "golang.org/x/oauth2" ) -// hard-defaults, may make configurable in the future if needed, -// but for now these are just safety limits to prevent unbounded memory usage -const MaxOAuthPendingSessions = 256 -const OAuthCleanupCount = 16 - var ( ErrUserNotFound = errors.New("user not found") ) -// We either store params for redirecting to an app after OAuth login, -// or for redirecting back to the authorize screen to continue OIDC -type OAuthCallbackParams struct { - LoginFor string `form:"login_for" url:"login_for"` - OIDCTicket string `form:"oidc_ticket" url:"oidc_ticket"` - OIDCScope string `form:"oidc_scope" url:"oidc_scope"` - OIDCName string `form:"oidc_name" url:"oidc_name"` - RedirectURI string `form:"redirect_uri" url:"redirect_uri"` -} - -type OAuthPendingSession struct { - State string - Verifier string - Token *oauth2.Token - Service IOAuthService - ExpiresAt time.Time - CallbackParams OAuthCallbackParams -} - type LoginAttempt struct { FailedAttempts int LastAttempt time.Time @@ -73,7 +47,6 @@ type AuthService struct { caches struct { login *cache.CacheStore[LoginAttempt] - oauth *cache.CacheStore[OAuthPendingSession] ldap *cache.CacheStore[[]string] } } @@ -107,7 +80,7 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) { } // dummy hash - dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost) + dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateSecureString(8)), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("failed to generate dummy hash: %w", err) @@ -116,11 +89,9 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) { service.dummyHash = string(dummyHash) // caches setup - oauthCache := cache.NewCacheStore[OAuthPendingSession](256) loginCache := cache.NewCacheStore[LoginAttempt](service.calculateLockdownLimit()) ldapCache := cache.NewCacheStore[[]string](1024) - service.caches.oauth = oauthCache service.caches.login = loginCache service.caches.ldap = ldapCache @@ -131,7 +102,6 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) { for { select { case <-ticker.C: - service.caches.oauth.Sweep() service.caches.login.Sweep() service.caches.ldap.Sweep() case <-ctx.Done(): @@ -160,9 +130,7 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) { return service, nil } -func (auth *AuthService) DummyPasswordCheck(password string) { - bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password)) -} +// User management functions func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { if auth.GetLocalUser(username) != nil { @@ -193,40 +161,6 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) return nil, ErrUserNotFound } -func (auth *AuthService) CheckUserPassword(search model.UserSearch, password string) (err error) { - switch search.Type { - case model.UserLocal: - user := auth.GetLocalUser(search.Username) - if user == nil { - return ErrUserNotFound - } - return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) - case model.UserLDAP: - if auth.ldap != nil { - defer func() { - bindErr := auth.ldap.BindService() - if bindErr != nil { - if err != nil { - err = fmt.Errorf("failed to rebind to ldap service account: %w, original error: %w", bindErr, err) - return - } - err = fmt.Errorf("failed to rebind to ldap service account: %w", bindErr) - } - }() - - err = auth.ldap.Bind(search.Username, password) - if err != nil { - return fmt.Errorf("failed to bind to ldap user: %w", err) - } - - return nil - } - default: - return errors.New("unknown user search type") - } - return errors.New("user authentication failed") -} - func (auth *AuthService) GetLocalUser(username string) *model.LocalUser { if auth.runtime.LocalUsers == nil { return nil @@ -267,10 +201,46 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) { }, nil } -func (auth *AuthService) rateLimitEnabled() bool { - return auth.config.Auth.LoginMaxRetries > 0 && auth.config.Auth.LoginTimeout > 0 +func (auth *AuthService) CheckUserPassword(search model.UserSearch, password string) (err error) { + switch search.Type { + case model.UserLocal: + user := auth.GetLocalUser(search.Username) + if user == nil { + return ErrUserNotFound + } + return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + case model.UserLDAP: + if auth.ldap != nil { + defer func() { + bindErr := auth.ldap.BindService() + if bindErr != nil { + if err != nil { + err = fmt.Errorf("failed to rebind to ldap service account: %w, original error: %w", bindErr, err) + return + } + err = fmt.Errorf("failed to rebind to ldap service account: %w", bindErr) + } + }() + + err = auth.ldap.Bind(search.Username, password) + if err != nil { + return fmt.Errorf("failed to bind to ldap user: %w", err) + } + + return nil + } + default: + return errors.New("unknown user search type") + } + return errors.New("user authentication failed") } +func (auth *AuthService) DummyPasswordCheck(password string) { + bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password)) +} + +// Account rate limiting + func (auth *AuthService) IsAccountLocked(identifier string, actions cache.CacheStoreActions[LoginAttempt]) (bool, int) { if !auth.rateLimitEnabled() { return false, 0 @@ -336,190 +306,69 @@ func (auth *AuthService) WithLoginAttempt(identifier string, f func(actions cach return err } -// We could also directly access the policyEngine.effectToAccess but -// I believe it's better to use the exported functions instead -func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool { - return auth.policyEngine.EvaluateFunc(func() Effect { - whitelist := auth.runtime.OAuthWhitelist - if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 { - whitelist = providerConfig.Whitelist - } - match, err := utils.CheckFilter(strings.Join(whitelist, ","), email) - if err != nil { - if errors.Is(err, utils.ErrFilterEmpty) { - return EffectAbstain - } - auth.log.App.Error().Err(err).Str("email", email).Msg("Failed to evaluate email whitelist filter, defaulting to deny") - return EffectDeny - } - if match { - return EffectAllow - } - return EffectDeny - }) +func (auth *AuthService) rateLimitEnabled() bool { + return auth.config.Auth.LoginMaxRetries > 0 && auth.config.Auth.LoginTimeout > 0 } -func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) { - if data.Provider == "tailscale" && auth.tailscale == nil { - return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user") - } - - u, err := uuid.NewRandom() - - if err != nil { - return nil, fmt.Errorf("failed to generate session uuid: %w", err) - } - - var expiry int - - if data.TotpPending { - expiry = 3600 - } else { - expiry = auth.config.Auth.SessionExpiry - } - - expiresAt := time.Now().Add(time.Duration(expiry) * time.Second) - - session := repository.CreateSessionParams{ - UUID: u.String(), - Username: data.Username, - Email: data.Email, - Name: data.Name, - Provider: data.Provider, - TotpPending: data.TotpPending, - OAuthGroups: data.OAuthGroups, - Expiry: expiresAt.Unix(), - CreatedAt: time.Now().Unix(), - OAuthName: data.OAuthName, - OAuthSub: data.OAuthSub, - } - - _, err = auth.queries.CreateSession(ctx, session) +func (auth *AuthService) calculateLockdownLimit() int { + userCount := len(auth.runtime.LocalUsers) - if err != nil { - return nil, fmt.Errorf("failed to create session entry: %w", err) + if auth.ldap != nil { + ldapUsers, err := auth.ldap.GetUserCount() + if err != nil { + auth.log.App.Warn().Err(err).Msg("Failed to get LDAP user count") + } else { + userCount += ldapUsers + } } - return &http.Cookie{ - Name: auth.runtime.SessionCookieName, - Value: session.UUID, - Path: "/", - Domain: auth.getCookieDomain(), - Expires: expiresAt, - MaxAge: int(time.Until(expiresAt).Seconds()), - Secure: auth.config.Auth.SecureCookie, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }, nil -} + limit := userCount * auth.config.Auth.LoginMaxRetries -func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http.Cookie, error) { - session, err := auth.queries.GetSession(ctx, uuid) + jitter, err := rand.Int(rand.Reader, big.NewInt(64)) if err != nil { - return nil, fmt.Errorf("failed to retrieve session: %w", err) - } - - currentTime := time.Now().Unix() - - var refreshThreshold int64 - - if auth.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) { - refreshThreshold = int64(auth.config.Auth.SessionExpiry / 2) + auth.log.App.Warn().Err(err).Msg("Failed to generate jitter for lockdown limit") } else { - refreshThreshold = int64(time.Hour.Seconds()) + limit += int(jitter.Int64()) } - if session.Expiry-currentTime > refreshThreshold { - return nil, nil + if limit < 256 { + limit = 256 } - newExpiry := session.Expiry + refreshThreshold - - _, err = auth.queries.UpdateSession(ctx, repository.UpdateSessionParams{ - Username: session.Username, - Email: session.Email, - Name: session.Name, - Provider: session.Provider, - TotpPending: session.TotpPending, - OAuthGroups: session.OAuthGroups, - Expiry: newExpiry, - OAuthName: session.OAuthName, - OAuthSub: session.OAuthSub, - UUID: session.UUID, - }) + return limit +} - if err != nil { - return nil, fmt.Errorf("failed to update session expiry: %w", err) - } - - return &http.Cookie{ - Name: auth.runtime.SessionCookieName, - Value: session.UUID, - Path: "/", - Domain: auth.getCookieDomain(), - Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second), - MaxAge: int(newExpiry - currentTime), - Secure: auth.config.Auth.SecureCookie, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }, nil +// Cookie management +type CreateCookieParams struct { + Name string + Value string + ExpiresAt time.Time } -func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.Cookie, error) { - err := auth.queries.DeleteSession(ctx, uuid) +func (auth *AuthService) CreateCookie(args CreateCookieParams) *http.Cookie { + domain := auth.runtime.CookieDomain - if err != nil { - auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database") + if !auth.config.Auth.SubdomainsEnabled { + domain = "" } return &http.Cookie{ - Name: auth.runtime.SessionCookieName, - Value: "", + Name: args.Name, + Value: args.Value, Path: "/", - Domain: auth.getCookieDomain(), - Expires: time.Now(), - MaxAge: -1, + Domain: domain, + Expires: args.ExpiresAt, + MaxAge: int(time.Until(args.ExpiresAt).Seconds()), Secure: auth.config.Auth.SecureCookie, HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }, nil -} - -func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*repository.Session, error) { - session, err := auth.queries.GetSession(ctx, uuid) - - if err != nil { - if errors.Is(err, repository.ErrNotFound) { - return nil, errors.New("session not found") - } - return nil, err + SameSite: http.SameSiteStrictMode, } - - currentTime := time.Now().Unix() - - if auth.config.Auth.SessionMaxLifetime != 0 && session.CreatedAt != 0 { - if currentTime-session.CreatedAt > int64(auth.config.Auth.SessionMaxLifetime) { - err = auth.queries.DeleteSession(ctx, uuid) - if err != nil { - return nil, fmt.Errorf("failed to delete expired session: %w", err) - } - return nil, fmt.Errorf("session max lifetime exceeded") - } - } - - if currentTime > session.Expiry { - err = auth.queries.DeleteSession(ctx, uuid) - if err != nil { - return nil, fmt.Errorf("failed to delete expired session: %w", err) - } - return nil, fmt.Errorf("session expired") - } - - return &session, nil } +// Configured providers + func (auth *AuthService) LocalAuthConfigured() bool { return len(auth.runtime.LocalUsers) > 0 } @@ -528,151 +377,40 @@ func (auth *AuthService) LDAPAuthConfigured() bool { return auth.ldap != nil } -func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbackParams) (string, error) { - service, ok := auth.oauthBroker.GetService(serviceName) - - if !ok { - return "", fmt.Errorf("oauth service not found: %s", serviceName) - } - - sessionId, err := uuid.NewRandom() - - if err != nil { - return "", fmt.Errorf("failed to generate session ID: %w", err) - } - - state := service.NewRandom() - verifier := service.NewRandom() - - session := OAuthPendingSession{ - State: state, - Verifier: verifier, - Service: service, - ExpiresAt: time.Now().Add(1 * time.Hour), - CallbackParams: params, - } - - auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10) - - return sessionId.String(), nil -} - -func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) { - session, err := auth.GetOAuthPendingSession(sessionId) - - if err != nil { - return "", err - } - - return session.Service.GetAuthURL(session.State, session.Verifier), nil +func (auth *AuthService) TailscaleAuthConfigured() bool { + return auth.tailscale != nil } -func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) { - session, ok := auth.caches.oauth.Get(sessionId) - - if !ok { - return nil, fmt.Errorf("oauth session not found: %s", sessionId) - } - - token, err := session.Service.GetToken(code, session.Verifier) - - if err != nil { - return nil, fmt.Errorf("failed to exchange code for token: %w", err) - } - - session.Token = token - - // ttl 0 means keep current expiration - ok = auth.caches.oauth.Update(sessionId, session, 0) - - if !ok { - return nil, fmt.Errorf("failed to update oauth session with token: %s", sessionId) - } - - return token, nil -} - -func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, error) { - session, err := auth.GetOAuthPendingSession(sessionId) - - if err != nil { - return nil, err - } - - if session.Token == nil { - return nil, fmt.Errorf("oauth token not found for session: %s", sessionId) - } - - userinfo, err := session.Service.GetUserinfo(session.Token) +// OAuth management - if err != nil { - return nil, fmt.Errorf("failed to get userinfo: %w", err) - } +func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool { + return auth.policyEngine.EvaluateFunc(func() Effect { + whitelist := auth.runtime.OAuthWhitelist - return userinfo, nil -} + if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 { + whitelist = providerConfig.Whitelist + } -func (auth *AuthService) GetOAuthService(sessionId string) (IOAuthService, error) { - session, err := auth.GetOAuthPendingSession(sessionId) + match, err := utils.CheckFilter(strings.Join(whitelist, ","), email) - if err != nil { - return nil, err - } + if err != nil { + if errors.Is(err, utils.ErrFilterEmpty) { + return EffectAbstain + } + auth.log.App.Error().Err(err).Str("email", email).Msg("Failed to evaluate email whitelist filter, defaulting to deny") + return EffectDeny + } - return session.Service, nil -} + if match { + return EffectAllow + } -func (auth *AuthService) EndOAuthSession(sessionId string) { - auth.caches.oauth.Delete(sessionId) + return EffectDeny + }) } -func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) { - session, exists := auth.caches.oauth.Get(sessionId) - - if !exists { - return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId) - } - - return &session, nil -} +// Testing functions, not useful for anything else -// ClearLoginAttempts is a testing function, not useful for anything else func (auth *AuthService) ClearLoginAttempts() { auth.caches.login.Clear() } - -func (auth *AuthService) calculateLockdownLimit() int { - userCount := len(auth.runtime.LocalUsers) - - if auth.ldap != nil { - ldapUsers, err := auth.ldap.GetUserCount() - if err != nil { - auth.log.App.Warn().Err(err).Msg("Failed to get LDAP user count") - } else { - userCount += ldapUsers - } - } - - limit := userCount * auth.config.Auth.LoginMaxRetries - - jitter, err := rand.Int(rand.Reader, big.NewInt(64)) - - if err != nil { - auth.log.App.Warn().Err(err).Msg("Failed to generate jitter for lockdown limit") - } else { - limit += int(jitter.Int64()) - } - - if limit < 256 { - limit = 256 - } - - return limit -} - -func (auth *AuthService) getCookieDomain() string { - if !auth.config.Auth.SubdomainsEnabled { - return "" - } - return auth.runtime.CookieDomain -} diff --git a/internal/service/identity_service.go b/internal/service/identity_service.go new file mode 100644 index 000000000..f350d2aeb --- /dev/null +++ b/internal/service/identity_service.go @@ -0,0 +1,271 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "uuid" + + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/repository" + "go.uber.org/dig" +) + +type LinkingMode string + +const ( + LinkingModeEmail LinkingMode = "email" + LinkingModeUsername LinkingMode = "username" +) + +type IdentityLocalContext struct { + Attributes model.UserAttributes `json:"attributes"` +} + +type IdentityOAuthContext struct { + Groups []string `json:"groups"` + Sub string `json:"sub"` + DisplayName string `json:"display_name"` + ID string `json:"id"` +} + +type IdentityLDAPContext struct { + Groups []string `json:"groups"` +} + +type IdentityTailscaleContext struct { + NodeName string `json:"node_name"` +} + +type IdentityContext struct { + Local IdentityLocalContext `json:"local"` + OAuth IdentityOAuthContext `json:"oauth"` + LDAP IdentityLDAPContext `json:"ldap"` + Tailscale IdentityTailscaleContext `json:"tailscale"` +} + +func (l LinkingMode) String() string { + return string(l) +} + +type IdentityServiceInput struct { + dig.In + + Store repository.Store + Runtime *model.RuntimeConfig +} + +type IdentityService struct { + store repository.Store + runtime *model.RuntimeConfig + + linkingMode LinkingMode +} + +func NewIdentityService(i IdentityServiceInput) *IdentityService { + return &IdentityService{ + store: i.Store, + runtime: i.Runtime, + } +} + +func (s *IdentityService) getUserFunc(args repository.CreateUserParams) func(ctx context.Context) (user repository.User, err error) { + if args.UUID != "" { + return func(ctx context.Context) (user repository.User, err error) { + return s.store.GetUserByUUID(ctx, args.UUID) + } + } + + switch s.linkingMode { + case LinkingModeEmail: + return func(ctx context.Context) (user repository.User, err error) { + return s.store.GetUserByEmail(ctx, args.Email) + } + case LinkingModeUsername: + return func(ctx context.Context) (user repository.User, err error) { + return s.store.GetUserByUsername(ctx, args.Username) + } + } + + return func(ctx context.Context) (user repository.User, err error) { + return repository.User{}, fmt.Errorf("unknown linking mode: %s", s.linkingMode.String()) + } +} + +func (s *IdentityService) ensureUser(ctx context.Context, args repository.CreateUserParams) (user repository.User, err error) { + user, err = s.getUserFunc(args)(ctx) + + if err != nil && !errors.Is(err, repository.ErrNotFound) { + return repository.User{}, fmt.Errorf("failed to get user: %w", err) + } + + if err == nil { + exp := repository.User{ + UUID: user.UUID, + Username: args.Username, + Email: args.Email, + Name: args.Name, + } + if user.Username != exp.Username || + user.Email != exp.Email || + user.Name != exp.Name { + err = s.store.UpdateUser(ctx, repository.UpdateUserParams{ + UUID: user.UUID, + Username: args.Username, + Email: args.Email, + Name: args.Name, + }) + if err != nil { + return repository.User{}, fmt.Errorf("failed to update user: %w", err) + } + } + return repository.User{ + UUID: user.UUID, + Username: args.Username, + Email: args.Email, + Name: args.Name, + }, nil + } + + u := uuid.New() + args.UUID = u.String() + + user, err = s.store.CreateUser(ctx, args) + + if err != nil { + return repository.User{}, fmt.Errorf("failed to create user: %w", err) + } + + return user, nil +} + +func (s *IdentityService) ensureIdentity(ctx context.Context, args repository.CreateIdentityParams) (identity repository.Identity, err error) { + identity, err = s.store.GetIdentityByUUID(ctx, args.UUID) + + if err != nil && !errors.Is(err, repository.ErrNotFound) { + return repository.Identity{}, fmt.Errorf("failed to get identity: %w", err) + } + + if err == nil { + return identity, nil + } + + u := uuid.New() + args.UUID = u.String() + + identity, err = s.store.CreateIdentity(ctx, args) + + if err != nil { + return repository.Identity{}, fmt.Errorf("failed to create identity: %w", err) + } + + return identity, nil +} + +type StoreIdentityParams struct { + // User information + Username string + Email string + Name string + + // Identity information + Provider model.ProviderType + ProviderUserID string + + // Context information + IdentityContext IdentityContext +} + +func (s *IdentityService) Store(ctx context.Context, params StoreIdentityParams) (identity repository.Identity, err error) { + if params.Provider == model.ProviderTailscale && + !slices.Contains(s.runtime.ConfiguredProviders, model.Provider{ + Name: "Tailscale", + ID: "tailscale", + }) { + return repository.Identity{}, fmt.Errorf("tailscale provider not configured, cannot create identity") + } + + encoded, err := json.Marshal(params.IdentityContext) + + if err != nil { + return repository.Identity{}, fmt.Errorf("failed to marshal context: %w", err) + } + + user, err := s.ensureUser(ctx, repository.CreateUserParams{ + Username: params.Username, + Email: params.Email, + Name: params.Name, + }) + + if err != nil { + return repository.Identity{}, err + } + + identity, err = s.ensureIdentity(ctx, repository.CreateIdentityParams{ + UserUUID: user.UUID, + Provider: params.Provider.String(), + ProviderUserID: params.ProviderUserID, + ContextJSON: string(encoded), + }) + + if err != nil { + return repository.Identity{}, fmt.Errorf("failed to create identity: %w", err) + } + + return identity, nil +} + +type IdentityResult struct { + // User information + Username string + Email string + Name string + + // Identity information + Provider model.ProviderType + ProviderUserID string + + // Context information + IdentityContext IdentityContext +} + +func (s *IdentityService) Load(ctx context.Context, uuid string) (result IdentityResult, err error) { + identity, err := s.store.GetIdentityByUUID(ctx, uuid) + + if err != nil { + return IdentityResult{}, fmt.Errorf("failed to get identity: %w", err) + } + + user, err := s.store.GetUserByUUID(ctx, identity.UserUUID) + + if err != nil { + return IdentityResult{}, fmt.Errorf("failed to get user: %w", err) + } + + var identityContext IdentityContext + + if identity.ContextJSON != "" { + err = json.Unmarshal([]byte(identity.ContextJSON), &identityContext) + + if err != nil { + return IdentityResult{}, fmt.Errorf("failed to unmarshal context: %w", err) + } + } + + provider, err := model.ProviderFromString(identity.Provider) + + if err != nil { + return IdentityResult{}, fmt.Errorf("failed to parse provider: %w", err) + } + + return IdentityResult{ + Username: user.Username, + Email: user.Email, + Name: user.Name, + Provider: provider, + ProviderUserID: identity.ProviderUserID, + IdentityContext: identityContext, + }, nil +} diff --git a/internal/service/oauth_broker_service.go b/internal/service/oauth_broker_service.go index e204cace5..df4862dc6 100644 --- a/internal/service/oauth_broker_service.go +++ b/internal/service/oauth_broker_service.go @@ -2,9 +2,14 @@ package service import ( "context" + "fmt" + "time" + "github.com/google/uuid" + "github.com/steveiliop56/ding" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/pkg/cache" "go.uber.org/dig" "slices" @@ -12,6 +17,26 @@ import ( "golang.org/x/oauth2" ) +// OAuthCallbackParams are the parameters that are passed to the callback URL +// after the user has logged in with OAuth +type OAuthCallbackParams struct { + LoginFor string `form:"login_for" url:"login_for"` + OIDCTicket string `form:"oidc_ticket" url:"oidc_ticket"` + OIDCScope string `form:"oidc_scope" url:"oidc_scope"` + OIDCName string `form:"oidc_name" url:"oidc_name"` + RedirectURI string `form:"redirect_uri" url:"redirect_uri"` +} + +// OAuthFlow represents the state of an OAuth flow +type OAuthFlow struct { + State string + Verifier string + Token *oauth2.Token + Service IOAuthService + ExpiresAt time.Time + CallbackParams OAuthCallbackParams +} + type IOAuthService interface { Name() string ID() string @@ -27,6 +52,10 @@ type OAuthBrokerService struct { services map[string]IOAuthService configs map[string]model.OAuthServiceConfig + + caches struct { + flows *cache.CacheStore[OAuthFlow] + } } var presets = map[string]func(config model.OAuthServiceConfig, ctx context.Context) *OAuthService{ @@ -39,6 +68,7 @@ type OAuthBrokerServiceInput struct { Log *logger.Logger Runtime *model.RuntimeConfig + Ding *ding.Ding Ctx context.Context } @@ -59,6 +89,22 @@ func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService { } } + service.caches.flows = cache.NewCacheStore[OAuthFlow](256) + + i.Ding.Go(func(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + service.caches.flows.Sweep() + case <-ctx.Done(): + return + } + } + }, ding.RingMinor) + return service } @@ -75,3 +121,89 @@ func (broker *OAuthBrokerService) GetService(name string) (IOAuthService, bool) service, exists := broker.services[name] return service, exists } + +func (broker *OAuthBrokerService) BeginOAuthFlow(serviceName string, params OAuthCallbackParams) (string, error) { + service, exists := broker.services[serviceName] + + if !exists { + return "", fmt.Errorf("oauth service not found: %s", serviceName) + } + + u := uuid.New() + state := service.NewRandom() + verifier := service.NewRandom() + + session := OAuthFlow{ + State: state, + Verifier: verifier, + Service: service, + ExpiresAt: time.Now().Add(1 * time.Hour), + CallbackParams: params, + } + + broker.caches.flows.Set(u.String(), session, time.Minute*10) + + return u.String(), nil +} + +func (broker *OAuthBrokerService) FlowAuthURL(flowId string) (string, bool) { + flow, ok := broker.caches.flows.Get(flowId) + + if !ok { + return "", false + } + + return flow.Service.GetAuthURL(flow.State, flow.Verifier), true +} + +func (broker *OAuthBrokerService) FlowOAuthToken(flowId string, code string) (*oauth2.Token, error) { + flow, ok := broker.caches.flows.Get(flowId) + + if !ok { + return nil, fmt.Errorf("oauth flow not found: %s", flowId) + } + + token, err := flow.Service.GetToken(code, flow.Verifier) + + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + flow.Token = token + + ok = broker.caches.flows.Update(flowId, flow, 0) + + if !ok { + return nil, fmt.Errorf("failed to update oauth flow in cache") + } + + return token, nil +} + +func (broker *OAuthBrokerService) FlowOAuthUserinfo(flowId string) (*model.Claims, error) { + flow, ok := broker.caches.flows.Get(flowId) + + if !ok { + return nil, fmt.Errorf("oauth flow not found: %s", flowId) + } + + if flow.Token == nil { + return nil, fmt.Errorf("oauth token not found for flow: %s", flowId) + } + + userinfo, err := flow.Service.GetUserinfo(flow.Token) + + if err != nil { + return nil, fmt.Errorf("failed to get userinfo: %w", err) + } + + return userinfo, nil +} + +func (broker *OAuthBrokerService) GetFlow(flowId string) (OAuthFlow, bool) { + return broker.caches.flows.Get(flowId) +} + +func (broker *OAuthBrokerService) EndFlow(flowId string) { + broker.caches.flows.Delete(flowId) +} diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 44709071f..958be4b1f 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -449,7 +449,7 @@ func (service *OIDCService) filterScopes(scopes []string) []string { } func (service *OIDCService) CreateCode(req AuthorizeRequest, userContext model.UserContext) string { - code := utils.GenerateString(32) + code := utils.GenerateSecureString(32) sub := service.CreateSub(userContext, req.ClientID) entry := AuthorizeCodeEntry{ @@ -626,8 +626,8 @@ func (service *OIDCService) GenerateAccessToken(ctx context.Context, client mode return nil, err } - accessToken := utils.GenerateString(32) - refreshToken := utils.GenerateString(32) + accessToken := utils.GenerateSecureString(32) + refreshToken := utils.GenerateSecureString(32) tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix() @@ -708,8 +708,8 @@ func (service *OIDCService) RefreshAccessToken(ctx context.Context, refreshToken return nil, err } - accessToken := utils.GenerateString(32) - newRefreshToken := utils.GenerateString(32) + accessToken := utils.GenerateSecureString(32) + newRefreshToken := utils.GenerateSecureString(32) tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix() refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix() @@ -742,7 +742,7 @@ func (service *OIDCService) RefreshAccessToken(ctx context.Context, refreshToken return &tokenResponse, nil } -func (service *OIDCService) GetSessionByToken(ctx context.Context, tokenHash string) (*repository.OidcSession, error) { +func (service *OIDCService) GetSessionByToken(ctx context.Context, tokenHash string) (*repository.OIDCSession, error) { entry, err := service.queries.GetOIDCSessionByAccessTokenHash(ctx, tokenHash) if err != nil { @@ -935,7 +935,7 @@ func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) } func (service *OIDCService) CreateAuthorizeRequestTicket(req AuthorizeRequest) string { - ticket := utils.GenerateString(32) + ticket := utils.GenerateSecureString(32) service.caches.authorize.Set(ticket, req, 10*time.Minute) @@ -1007,7 +1007,7 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt { return parsedPromps } -func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) { +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, @@ -1023,20 +1023,20 @@ func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, return &entry, nil } -func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) { +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) { +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 + return repository.OIDCConsent{}, err } merged := scope @@ -1056,7 +1056,7 @@ func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, sco if err != nil { service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent") - return repository.OidcConsent{}, err + return repository.OIDCConsent{}, err } return consent, nil diff --git a/internal/service/sessions_service.go b/internal/service/sessions_service.go new file mode 100644 index 000000000..c9b4355a7 --- /dev/null +++ b/internal/service/sessions_service.go @@ -0,0 +1,201 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + "uuid" + + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/repository" + "github.com/tinyauthapp/tinyauth/internal/utils" + "go.uber.org/dig" +) + +var ( + ErrSessionNotFound = fmt.Errorf("session not found") +) + +type SessionLocalContext struct { + TOTPPending bool `json:"totp_pending"` +} + +type SessionContext struct { + Local SessionLocalContext `json:"local"` +} + +type SessionsService struct { + identity *IdentityService + + store repository.Store + config *model.Config + runtime *model.RuntimeConfig +} + +type SessionsServiceInput struct { + dig.In + + Store repository.Store + Identity *IdentityService + Config *model.Config + Runtime *model.RuntimeConfig +} + +func NewSessionsService(i SessionsServiceInput) *SessionsService { + return &SessionsService{ + store: i.Store, + identity: i.Identity, + config: i.Config, + runtime: i.Runtime, + } +} + +func (s *SessionsService) Create(ctx context.Context, identityUUID string, sessionContext SessionContext) (repository.Session, error) { + // validate that the identity exists (we use map[string]any because we know the ctx is json) + _, err := s.identity.Load[map[string]any](ctx, identityUUID) + + if err != nil { + return repository.Session{}, err + } + + u := uuid.New() + csrfToken := utils.GenerateSecureString(32) + createdAt := time.Now() + + expiresAt := createdAt.Add(time.Duration(s.config.Auth.SessionExpiry) * time.Second) + + if sessionContext.Local.TOTPPending { + expiresAt = createdAt.Add(time.Duration(3600) * time.Second) + } + + contextJSON, err := json.Marshal(sessionContext) + + if err != nil { + return repository.Session{}, fmt.Errorf("failed to marshal session context: %w", err) + } + + session, err := s.store.CreateSession(ctx, repository.CreateSessionParams{ + UUID: u.String(), + IdentityUUID: identityUUID, + CSRFToken: csrfToken, + ExpiresAt: expiresAt.Unix(), + CreatedAt: createdAt.Unix(), + ContextJSON: string(contextJSON), + }) + + if err != nil { + return repository.Session{}, fmt.Errorf("failed to create session: %w", err) + } + + return session, nil +} + +func (s *SessionsService) Refresh(ctx context.Context, uuid string) (repository.Session, error) { + session, err := s.store.GetSessionByUUID(ctx, uuid) + + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.Session{}, ErrSessionNotFound + } + return repository.Session{}, fmt.Errorf("failed to get session: %w", err) + } + + t := time.Now() + + refreshThreshold := int64(time.Hour.Seconds()) + + if s.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) { + refreshThreshold = int64(s.config.Auth.SessionExpiry / 2) + } + + if int64(time.Unix(session.ExpiresAt, 0).Sub(t).Seconds()) > refreshThreshold { + return repository.Session{}, nil + } + + expiresAt := time.Unix(session.ExpiresAt, 0).Add(time.Duration(refreshThreshold) * time.Second) + + session, err = s.store.UpdateSessionByUUID(ctx, repository.UpdateSessionByUUIDParams{ + UUID: session.UUID, + ExpiresAt: expiresAt.Unix(), + ContextJSON: session.ContextJSON, + }) + + if err != nil { + return repository.Session{}, fmt.Errorf("failed to update session expiry: %w", err) + } + + return session, nil +} + +func (s *SessionsService) Delete(ctx context.Context, uuid string) error { + err := s.store.DeleteSessionByUUID(ctx, uuid) + + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil + } + return fmt.Errorf("failed to delete session: %w", err) + } + + return nil +} + +type SessionGetResult struct { + Session repository.Session + SessionContext SessionContext +} + +func (s *SessionsService) Get(ctx context.Context, uuid string) (SessionGetResult, error) { + session, err := s.store.GetSessionByUUID(ctx, uuid) + + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return SessionGetResult{}, ErrSessionNotFound + } + return SessionGetResult{}, fmt.Errorf("failed to get session: %w", err) + } + + var sessionContext SessionContext + + err = json.Unmarshal([]byte(session.ContextJSON), &sessionContext) + + if err != nil { + return SessionGetResult{}, fmt.Errorf("failed to unmarshal session context: %w", err) + } + + return SessionGetResult{ + Session: session, + SessionContext: sessionContext, + }, nil +} + +func (s *SessionsService) Update(ctx context.Context, uuid string, sessionContext SessionContext) (repository.Session, error) { + session, err := s.store.GetSessionByUUID(ctx, uuid) + + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.Session{}, ErrSessionNotFound + } + return repository.Session{}, fmt.Errorf("failed to get session: %w", err) + } + + contextJSON, err := json.Marshal(sessionContext) + + if err != nil { + return repository.Session{}, fmt.Errorf("failed to marshal session context: %w", err) + } + + session, err = s.store.UpdateSessionByUUID(ctx, repository.UpdateSessionByUUIDParams{ + UUID: session.UUID, + ExpiresAt: session.ExpiresAt, + ContextJSON: string(contextJSON), + }) + + if err != nil { + return repository.Session{}, fmt.Errorf("failed to update session: %w", err) + } + + return session, nil +} diff --git a/internal/utils/security_utils.go b/internal/utils/security_utils.go index c6bef1085..d43e0d32b 100644 --- a/internal/utils/security_utils.go +++ b/internal/utils/security_utils.go @@ -112,7 +112,7 @@ func GenerateUUID(str string) string { return uuid.String() } -func GenerateString(length int) string { +func GenerateSecureString(length int) string { src := make([]byte, length) rand.Read(src) return base64.RawURLEncoding.EncodeToString(src)[:length] diff --git a/sql/postgres/session.queries.sql b/sql/postgres/session.queries.sql index 1fbf7536d..eeb97eba5 100644 --- a/sql/postgres/session.queries.sql +++ b/sql/postgres/session.queries.sql @@ -11,6 +11,13 @@ INSERT INTO "sessions" ( ) RETURNING *; +-- name: UpdateSessionByUUID :one +UPDATE "sessions" SET + "expires_at" = $1, + "context_json" = $2 +WHERE "uuid" = $3 +RETURNING *; + -- name: GetSessionByUUID :one SELECT * FROM "sessions" WHERE "uuid" = $1; diff --git a/sql/postgres/users.queries.sql b/sql/postgres/users.queries.sql index 10812470f..c2dd24f75 100644 --- a/sql/postgres/users.queries.sql +++ b/sql/postgres/users.queries.sql @@ -12,3 +12,18 @@ RETURNING *; -- name: GetUserByUUID :one SELECT * FROM "users" WHERE "uuid" = $1; + +-- name: GetUserByUsername :one +SELECT * FROM "users" +WHERE "username" = $1; + +-- name: GetUserByEmail :one +SELECT * FROM "users" +WHERE "email" = $1; + +-- name: UpdateUser :exec +UPDATE "users" SET + "username" = $1, + "email" = $2, + "name" = $3 +WHERE "uuid" = $4; \ No newline at end of file diff --git a/sql/sqlite/session.queries.sql b/sql/sqlite/session.queries.sql index b923ca323..6a710fa0a 100644 --- a/sql/sqlite/session.queries.sql +++ b/sql/sqlite/session.queries.sql @@ -11,6 +11,13 @@ INSERT INTO "sessions" ( ) RETURNING *; +-- name: UpdateSessionByUUID :one +UPDATE "sessions" SET + "expires_at" = ?, + "context_json" = ? +WHERE "uuid" = ? +RETURNING *; + -- name: GetSessionByUUID :one SELECT * FROM "sessions" WHERE "uuid" = ?; diff --git a/sql/sqlite/users.queries.sql b/sql/sqlite/users.queries.sql index 155b93024..a96d0b118 100644 --- a/sql/sqlite/users.queries.sql +++ b/sql/sqlite/users.queries.sql @@ -12,3 +12,18 @@ RETURNING *; -- name: GetUserByUUID :one SELECT * FROM "users" WHERE "uuid" = ?; + +-- name: GetUserByUsername :one +SELECT * FROM "users" +WHERE "username" = ?; + +-- name: GetUserByEmail :one +SELECT * FROM "users" +WHERE "email" = ?; + +-- name: UpdateUser :exec +UPDATE "users" SET + "username" = ?, + "email" = ?, + "name" = ? +WHERE "uuid" = ?; \ No newline at end of file diff --git a/sqlc.yml b/sqlc.yml index f9b49af25..66909961f 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -15,6 +15,7 @@ sql: csrf_token: "CSRFToken" oidc_session: "OIDCSession" oidc_consent: "OIDCConsent" + identity_uuid: "IdentityUUID" overrides: - column: "oidc_sessions.nonce" go_type: "string" @@ -32,4 +33,5 @@ sql: context_json: "ContextJSON" csrf_token: "CSRFToken" oidc_session: "OIDCSession" - oidc_consent: "OIDCConsent" \ No newline at end of file + oidc_consent: "OIDCConsent" + identity_uuid: "IdentityUUID" \ No newline at end of file From c8ebbbb91eff35a0ccdb57ea6fcd3233be21d8cb Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 6 Nov 2026 19:55:48 +0200 Subject: [PATCH 03/16] refactor: migrate oauth controller to new identity model --- .../postgres/000004_identity.up.sql | 1 + .../migrations/sqlite/000012_identity.up.sql | 1 + internal/controller/oauth_controller.go | 156 +++++++++++------- internal/model/runtime.go | 2 +- internal/repository/models.go | 2 + .../postgres/identities.queries.sql.go | 11 +- internal/repository/postgres/models.go | 1 + .../sqlite/identities.queries.sql.go | 11 +- internal/repository/sqlite/models.go | 1 + internal/service/identity_service.go | 15 +- internal/service/oauth_broker_service.go | 6 +- sql/postgres/identities.queries.sql | 3 +- sql/postgres/identities.schemas.sql | 1 + sql/sqlite/identities.queries.sql | 3 +- sql/sqlite/identities.schemas.sql | 1 + 15 files changed, 135 insertions(+), 80 deletions(-) diff --git a/internal/assets/migrations/postgres/000004_identity.up.sql b/internal/assets/migrations/postgres/000004_identity.up.sql index 598e7eef8..1f0856b20 100644 --- a/internal/assets/migrations/postgres/000004_identity.up.sql +++ b/internal/assets/migrations/postgres/000004_identity.up.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS "users" ( CREATE TABLE IF NOT EXISTS "identities" ( "uuid" TEXT PRIMARY KEY, "user_uuid" TEXT NOT NULL, + "provider_type" TEXT NOT NULL, "provider" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "context_json" TEXT NOT NULL, diff --git a/internal/assets/migrations/sqlite/000012_identity.up.sql b/internal/assets/migrations/sqlite/000012_identity.up.sql index 5ae39858c..4f69339c9 100644 --- a/internal/assets/migrations/sqlite/000012_identity.up.sql +++ b/internal/assets/migrations/sqlite/000012_identity.up.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS "users" ( CREATE TABLE IF NOT EXISTS "identities" ( "uuid" TEXT PRIMARY KEY, "user_uuid" TEXT NOT NULL, + "provider_type" TEXT NOT NULL, "provider" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "context_json" TEXT NOT NULL, diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index f48c3fde5..c0a4f66fe 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -8,7 +8,6 @@ import ( "time" "github.com/tinyauthapp/tinyauth/internal/model" - "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" @@ -24,28 +23,37 @@ type OAuthRequest struct { } type OAuthController struct { - log *logger.Logger - config *model.Config - runtime *model.RuntimeConfig - auth *service.AuthService + log *logger.Logger + config *model.Config + runtime *model.RuntimeConfig + auth *service.AuthService + broker *service.OAuthBrokerService + identity *service.IdentityService + sessions *service.SessionsService } type OAuthControllerInput struct { dig.In - Log *logger.Logger - Config *model.Config - RuntimeConfig *model.RuntimeConfig - RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` - AuthService *service.AuthService + Log *logger.Logger + Config *model.Config + RuntimeConfig *model.RuntimeConfig + RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` + AuthService *service.AuthService + OAuthBroker *service.OAuthBrokerService + IdentityService *service.IdentityService + SessionsService *service.SessionsService } func NewOAuthController(i OAuthControllerInput) *OAuthController { controller := &OAuthController{ - log: i.Log, - config: i.Config, - runtime: i.RuntimeConfig, - auth: i.AuthService, + log: i.Log, + config: i.Config, + runtime: i.RuntimeConfig, + auth: i.AuthService, + broker: i.OAuthBroker, + identity: i.IdentityService, + sessions: i.SessionsService, } oauthGroup := i.RouterGroup.Group("/oauth") @@ -88,10 +96,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { } } - sessionId, err := controller.auth.NewOAuthSession(req.Provider, reqParams) + flowId, ok := controller.broker.BeginOAuthFlow(req.Provider, reqParams) - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session") + if !ok { + controller.log.App.Error().Err(err).Msg("Failed to initiate OAuth flow") c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -99,10 +107,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { return } - authUrl, err := controller.auth.GetOAuthURL(sessionId) + authUrl, ok := controller.broker.FlowAuthURL(flowId) - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to get OAuth URL for session") + if !ok { + controller.log.App.Error().Err(err).Msg("Failed to get OAuth URL for flow") c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -110,7 +118,13 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { return } - c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true) + cookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.OAuthFlowCookieName, + Value: flowId, + ExpiresAt: time.Now().Add(time.Hour), + }) + + http.SetCookie(c.Writer, cookie) c.JSON(200, gin.H{ "status": 200, @@ -132,35 +146,41 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - sessionIdCookie, err := c.Cookie(controller.runtime.OAuthSessionCookieName) + flowId, err := c.Cookie(controller.runtime.OAuthFlowCookieName) if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to get OAuth session cookie") + controller.log.App.Error().Err(err).Msg("Failed to get OAuth flow cookie") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) return } - c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true) + cookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.OAuthFlowCookieName, + Value: "", + ExpiresAt: time.Now().Add(-time.Hour), + }) + + http.SetCookie(c.Writer, cookie) - oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie) + flow, ok := controller.broker.GetFlow(flowId) - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to get pending OAuth session") + if !ok { + controller.log.App.Error().Err(err).Msg("Failed to get OAuth flow") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) return } - defer controller.auth.EndOAuthSession(sessionIdCookie) + defer controller.broker.EndFlow(flowId) state := c.Query("state") - if state != oauthPendingSession.State { + if state != flow.State { controller.log.App.Warn().Msg("OAuth state mismatch") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) return } code := c.Query("code") - _, err = controller.auth.GetOAuthToken(sessionIdCookie, code) + _, err = controller.broker.FlowOAuthToken(flowId, code) if err != nil { controller.log.App.Error().Err(err).Msg("Failed to exchange code for token") @@ -168,7 +188,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie) + user, err := controller.broker.FlowOAuthUserinfo(flowId) if err != nil { controller.log.App.Error().Err(err).Msg("Failed to get user info from OAuth provider") @@ -188,23 +208,15 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - svc, err := controller.auth.GetOAuthService(sessionIdCookie) - - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) - return - } - - if svc.ID() != req.Provider { - controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID()) + if flow.Service.ID() != req.Provider { + controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, flow.Service.ID()) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) return } - if !controller.auth.IsEmailWhitelisted(svc.ID(), user.Email) { + if !controller.auth.IsEmailWhitelisted(flow.Service.ID(), user.Email) { controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access") - controller.log.AuditLoginFailure(user.Email, svc.ID(), c.ClientIP(), "email not whitelisted") + controller.log.AuditLoginFailure(user.Email, flow.Service.ID(), c.ClientIP(), "email not whitelisted") queries, err := query.Values(UnauthorizedQuery{ Username: user.Email, @@ -220,39 +232,61 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - oauthUserInfo := controller.createOAuthUserInfo(oauthUserInfo{ + userinfo := controller.createOAuthUserInfo(oauthUserInfo{ Username: user.PreferredUsername, Email: user.Email, Name: user.Name, }) - sessionCookie := repository.Session{ - Username: oauthUserInfo.Username, - Name: oauthUserInfo.Name, - Email: oauthUserInfo.Email, - Provider: svc.ID(), - OAuthGroups: utils.CoalesceToString(user.Groups), - OAuthName: svc.Name(), - OAuthSub: user.Sub, + identityContext := service.IdentityContext{ + OAuth: service.IdentityOAuthContext{ + Groups: utils.CoalesceToString(user.Groups), + Sub: user.Sub, + DisplayName: user.Name, + }, + } + + identity, err := controller.identity.Store(c, service.StoreIdentityParams{ + Username: userinfo.Username, + Email: userinfo.Email, + Name: userinfo.Name, + ProviderID: flow.Service.ID(), + ProviderType: model.ProviderOAuth, + ProviderUserID: user.Sub, + IdentityContext: identityContext, + }) + + if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to store identity") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) + return } - controller.log.App.Debug().Msg("Creating session cookie for user") + controller.log.App.Debug().Str("username", userinfo.Username).Str("email", userinfo.Email).Msg("Identity created") - cookie, err := controller.auth.CreateSession(c, sessionCookie) + session, err := controller.sessions.Create(c, identity.UUID, service.SessionContext{}) if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to create session cookie") + controller.log.App.Error().Err(err).Msg("Failed to create session") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) return } - http.SetCookie(c.Writer, cookie) + controller.log.App.Debug().Str("username", userinfo.Username).Str("email", userinfo.Email).Msg("Session created") + + sessionCookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.SessionCookieName, + Value: session.UUID, + ExpiresAt: time.Unix(session.ExpiresAt, 0), + }) + + http.SetCookie(c.Writer, sessionCookie) - controller.log.AuditLoginSuccess(sessionCookie.Username, sessionCookie.Provider, c.ClientIP()) + controller.log.AuditLoginSuccess(userinfo.Username, flow.Service.ID(), c.ClientIP()) - if controller.isOidcRequest(oauthPendingSession.CallbackParams) { + if controller.isOidcRequest(flow.CallbackParams) { controller.log.App.Debug().Msg("OIDC request detected, redirecting to authorization endpoint with callback params") - queries, err := query.Values(oauthPendingSession.CallbackParams) + queries, err := query.Values(flow.CallbackParams) if err != nil { controller.log.App.Error().Err(err).Msg("Failed to encode OIDC callback query") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) @@ -262,9 +296,9 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - if oauthPendingSession.CallbackParams.RedirectURI != "" { + if flow.CallbackParams.RedirectURI != "" { queries, err := query.Values(RedirectQuery{ - RedirectURI: oauthPendingSession.CallbackParams.RedirectURI, + RedirectURI: flow.CallbackParams.RedirectURI, LoginFor: FrontendLoginForApp, }) diff --git a/internal/model/runtime.go b/internal/model/runtime.go index 668119528..ad8ec8fda 100644 --- a/internal/model/runtime.go +++ b/internal/model/runtime.go @@ -5,7 +5,7 @@ type RuntimeConfig struct { UUID string CookieDomain string SessionCookieName string - OAuthSessionCookieName string + OAuthFlowCookieName string LocalUsers []LocalUser OAuthProviders map[string]OAuthServiceConfig OAuthWhitelist []string diff --git a/internal/repository/models.go b/internal/repository/models.go index de2419603..49069f42e 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -31,6 +31,7 @@ type UpdateUserParams struct { type Identity struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string @@ -39,6 +40,7 @@ type Identity struct { type CreateIdentityParams struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string diff --git a/internal/repository/postgres/identities.queries.sql.go b/internal/repository/postgres/identities.queries.sql.go index 27589e444..80858f1f1 100644 --- a/internal/repository/postgres/identities.queries.sql.go +++ b/internal/repository/postgres/identities.queries.sql.go @@ -13,18 +13,20 @@ const createIdentity = `-- name: CreateIdentity :one INSERT INTO "identities" ( "uuid", "user_uuid", + "provider_type", "provider", "provider_user_id", "context_json" ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4, $5, $6 ) -RETURNING uuid, user_uuid, provider, provider_user_id, context_json +RETURNING uuid, user_uuid, provider_type, provider, provider_user_id, context_json ` type CreateIdentityParams struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string @@ -34,6 +36,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) row := q.db.QueryRowContext(ctx, createIdentity, arg.UUID, arg.UserUUID, + arg.ProviderType, arg.Provider, arg.ProviderUserID, arg.ContextJSON, @@ -42,6 +45,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) err := row.Scan( &i.UUID, &i.UserUUID, + &i.ProviderType, &i.Provider, &i.ProviderUserID, &i.ContextJSON, @@ -50,7 +54,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) } const getIdentityByUUID = `-- name: GetIdentityByUUID :one -SELECT uuid, user_uuid, provider, provider_user_id, context_json FROM "identities" +SELECT uuid, user_uuid, provider_type, provider, provider_user_id, context_json FROM "identities" WHERE "uuid" = $1 ` @@ -60,6 +64,7 @@ func (q *Queries) GetIdentityByUUID(ctx context.Context, uuid string) (Identity, err := row.Scan( &i.UUID, &i.UserUUID, + &i.ProviderType, &i.Provider, &i.ProviderUserID, &i.ContextJSON, diff --git a/internal/repository/postgres/models.go b/internal/repository/postgres/models.go index e9eeb702d..f6259e5ad 100644 --- a/internal/repository/postgres/models.go +++ b/internal/repository/postgres/models.go @@ -7,6 +7,7 @@ package postgres type Identity struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string diff --git a/internal/repository/sqlite/identities.queries.sql.go b/internal/repository/sqlite/identities.queries.sql.go index 231772c70..93ceb1f63 100644 --- a/internal/repository/sqlite/identities.queries.sql.go +++ b/internal/repository/sqlite/identities.queries.sql.go @@ -13,18 +13,20 @@ const createIdentity = `-- name: CreateIdentity :one INSERT INTO "identities" ( "uuid", "user_uuid", + "provider_type", "provider", "provider_user_id", "context_json" ) VALUES ( - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ? ) -RETURNING uuid, user_uuid, provider, provider_user_id, context_json +RETURNING uuid, user_uuid, provider_type, provider, provider_user_id, context_json ` type CreateIdentityParams struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string @@ -34,6 +36,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) row := q.db.QueryRowContext(ctx, createIdentity, arg.UUID, arg.UserUUID, + arg.ProviderType, arg.Provider, arg.ProviderUserID, arg.ContextJSON, @@ -42,6 +45,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) err := row.Scan( &i.UUID, &i.UserUUID, + &i.ProviderType, &i.Provider, &i.ProviderUserID, &i.ContextJSON, @@ -50,7 +54,7 @@ func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) } const getIdentityByUUID = `-- name: GetIdentityByUUID :one -SELECT uuid, user_uuid, provider, provider_user_id, context_json FROM "identities" +SELECT uuid, user_uuid, provider_type, provider, provider_user_id, context_json FROM "identities" WHERE "uuid" = ? ` @@ -60,6 +64,7 @@ func (q *Queries) GetIdentityByUUID(ctx context.Context, uuid string) (Identity, err := row.Scan( &i.UUID, &i.UserUUID, + &i.ProviderType, &i.Provider, &i.ProviderUserID, &i.ContextJSON, diff --git a/internal/repository/sqlite/models.go b/internal/repository/sqlite/models.go index 0e79217ed..231816d9d 100644 --- a/internal/repository/sqlite/models.go +++ b/internal/repository/sqlite/models.go @@ -7,6 +7,7 @@ package sqlite type Identity struct { UUID string UserUUID string + ProviderType string Provider string ProviderUserID string ContextJSON string diff --git a/internal/service/identity_service.go b/internal/service/identity_service.go index f350d2aeb..7c742b6c9 100644 --- a/internal/service/identity_service.go +++ b/internal/service/identity_service.go @@ -25,10 +25,9 @@ type IdentityLocalContext struct { } type IdentityOAuthContext struct { - Groups []string `json:"groups"` - Sub string `json:"sub"` - DisplayName string `json:"display_name"` - ID string `json:"id"` + Groups string `json:"groups"` + Sub string `json:"sub"` + DisplayName string `json:"display_name"` } type IdentityLDAPContext struct { @@ -171,7 +170,8 @@ type StoreIdentityParams struct { Name string // Identity information - Provider model.ProviderType + ProviderID string + ProviderType model.ProviderType ProviderUserID string // Context information @@ -179,7 +179,7 @@ type StoreIdentityParams struct { } func (s *IdentityService) Store(ctx context.Context, params StoreIdentityParams) (identity repository.Identity, err error) { - if params.Provider == model.ProviderTailscale && + if params.ProviderType == model.ProviderTailscale && !slices.Contains(s.runtime.ConfiguredProviders, model.Provider{ Name: "Tailscale", ID: "tailscale", @@ -205,7 +205,8 @@ func (s *IdentityService) Store(ctx context.Context, params StoreIdentityParams) identity, err = s.ensureIdentity(ctx, repository.CreateIdentityParams{ UserUUID: user.UUID, - Provider: params.Provider.String(), + Provider: params.ProviderID, + ProviderType: params.ProviderType.String(), ProviderUserID: params.ProviderUserID, ContextJSON: string(encoded), }) diff --git a/internal/service/oauth_broker_service.go b/internal/service/oauth_broker_service.go index df4862dc6..c4f2bff8f 100644 --- a/internal/service/oauth_broker_service.go +++ b/internal/service/oauth_broker_service.go @@ -122,11 +122,11 @@ func (broker *OAuthBrokerService) GetService(name string) (IOAuthService, bool) return service, exists } -func (broker *OAuthBrokerService) BeginOAuthFlow(serviceName string, params OAuthCallbackParams) (string, error) { +func (broker *OAuthBrokerService) BeginOAuthFlow(serviceName string, params OAuthCallbackParams) (string, bool) { service, exists := broker.services[serviceName] if !exists { - return "", fmt.Errorf("oauth service not found: %s", serviceName) + return "", false } u := uuid.New() @@ -143,7 +143,7 @@ func (broker *OAuthBrokerService) BeginOAuthFlow(serviceName string, params OAut broker.caches.flows.Set(u.String(), session, time.Minute*10) - return u.String(), nil + return u.String(), true } func (broker *OAuthBrokerService) FlowAuthURL(flowId string) (string, bool) { diff --git a/sql/postgres/identities.queries.sql b/sql/postgres/identities.queries.sql index 2f74e2968..eb7b5f611 100644 --- a/sql/postgres/identities.queries.sql +++ b/sql/postgres/identities.queries.sql @@ -2,11 +2,12 @@ INSERT INTO "identities" ( "uuid", "user_uuid", + "provider_type", "provider", "provider_user_id", "context_json" ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4, $5, $6 ) RETURNING *; diff --git a/sql/postgres/identities.schemas.sql b/sql/postgres/identities.schemas.sql index 36a60b121..694e5e4b4 100644 --- a/sql/postgres/identities.schemas.sql +++ b/sql/postgres/identities.schemas.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS "identities" ( "uuid" TEXT PRIMARY KEY, "user_uuid" TEXT NOT NULL, + "provider_type" TEXT NOT NULL, "provider" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "context_json" TEXT NOT NULL, diff --git a/sql/sqlite/identities.queries.sql b/sql/sqlite/identities.queries.sql index 79743369b..afe13ef37 100644 --- a/sql/sqlite/identities.queries.sql +++ b/sql/sqlite/identities.queries.sql @@ -2,11 +2,12 @@ INSERT INTO "identities" ( "uuid", "user_uuid", + "provider_type", "provider", "provider_user_id", "context_json" ) VALUES ( - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ? ) RETURNING *; diff --git a/sql/sqlite/identities.schemas.sql b/sql/sqlite/identities.schemas.sql index 36a60b121..694e5e4b4 100644 --- a/sql/sqlite/identities.schemas.sql +++ b/sql/sqlite/identities.schemas.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS "identities" ( "uuid" TEXT PRIMARY KEY, "user_uuid" TEXT NOT NULL, + "provider_type" TEXT NOT NULL, "provider" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "context_json" TEXT NOT NULL, From a50c3c183357b77c255aa8fded0c2fb167482ab5 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 6 Nov 2026 23:26:07 +0200 Subject: [PATCH 04/16] refactor: update context middleware --- internal/controller/oauth_controller.go | 16 ++--- internal/middleware/context_middleware.go | 67 ++++++++++++++++----- internal/model/context.go | 66 +++++++++++---------- internal/model/runtime.go | 66 +++++++++++++++++++++ internal/service/identity_service.go | 72 ++++------------------- internal/service/sessions_service.go | 30 +++------- 6 files changed, 175 insertions(+), 142 deletions(-) diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index c0a4f66fe..d46ddb9f5 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -238,11 +238,10 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { Name: user.Name, }) - identityContext := service.IdentityContext{ - OAuth: service.IdentityOAuthContext{ + identityContext := model.IdentityContext{ + OAuth: model.IdentityOAuthContext{ Groups: utils.CoalesceToString(user.Groups), - Sub: user.Sub, - DisplayName: user.Name, + DisplayName: flow.Service.Name(), }, } @@ -264,7 +263,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { controller.log.App.Debug().Str("username", userinfo.Username).Str("email", userinfo.Email).Msg("Identity created") - session, err := controller.sessions.Create(c, identity.UUID, service.SessionContext{}) + session, err := controller.sessions.Create(c, identity.UUID, model.SessionContext{}) if err != nil { controller.log.App.Error().Err(err).Msg("Failed to create session") @@ -319,13 +318,6 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackPar return params.LoginFor == string(FrontendLoginForOIDC) } -func (controller *OAuthController) getCookieDomain() string { - if !controller.config.Auth.SubdomainsEnabled { - return "" - } - return controller.runtime.CookieDomain -} - func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { v := validators.NewDomainValidator(validators.DomainValidatorOptions{ WithPort: true, diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 38955d17a..4cb3a9098 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -45,6 +45,8 @@ type ContextMiddleware struct { auth *service.AuthService broker *service.OAuthBrokerService tailscale *service.TailscaleService + session *service.SessionsService + identity *service.IdentityService } type ContextMiddlewareInput struct { @@ -56,6 +58,8 @@ type ContextMiddlewareInput struct { AuthService *service.AuthService BrokerService *service.OAuthBrokerService TailscaleService *service.TailscaleService + Session *service.SessionsService + Identity *service.IdentityService } func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware { @@ -66,6 +70,8 @@ func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware { auth: i.AuthService, broker: i.BrokerService, tailscale: i.TailscaleService, + session: i.Session, + identity: i.Identity, } } @@ -81,7 +87,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { if err == nil { userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP()) - if err == nil { + if err == nil && userContext != nil { if cookie != nil { http.SetCookie(c.Writer, cookie) } @@ -92,7 +98,9 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { return } - m.log.App.Debug().Msgf("Error authenticating session cookie: %v", err) + if userContext != nil { + m.log.App.Debug().Msgf("Error authenticating session cookie: %v", err) + } } authHeader := c.GetHeader("x-tinyauth-authorization") @@ -127,7 +135,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { return } - // Lastly check if we have a tailscale session to add + // Lastly, check if we have a tailscale session to add if m.tailscale != nil { tailscaleContext, err := m.tailscaleWhois(c.ClientIP()) @@ -149,13 +157,22 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip string) (*model.UserContext, *http.Cookie, error) { - session, err := m.auth.GetSession(ctx, uuid) + session, err := m.session.Get(ctx, uuid) if err != nil { + if errors.Is(err, service.ErrSessionNotFound) { + return nil, nil, nil + } return nil, nil, fmt.Errorf("error retrieving session: %w", err) } - userContext, err := new(model.UserContext).NewFromSession(session) + identity, err := m.identity.Load(ctx, session.Session.IdentityUUID) + + if err != nil { + return nil, nil, fmt.Errorf("error retrieving identity: %w", err) + } + + userContext, err := new(model.UserContext).NewFromSession(session, identity) if err != nil { return nil, nil, fmt.Errorf("error creating user context from session: %w", err) @@ -176,12 +193,12 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri userContext.Local.Attributes = user.Attributes - if userContext.Local.Attributes.Name == "" { - userContext.Local.Attributes.Name = utils.Capitalize(user.Username) + if userContext.Local.Attributes.Name != "" { + userContext.Local.Name = userContext.Local.Attributes.Name } - if userContext.Local.Attributes.Email == "" { - userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) + if userContext.Local.Attributes.Email != "" { + userContext.Local.Email = userContext.Local.Attributes.Email } case model.ProviderTailscale: tailscaleContext, err := m.tailscaleWhois(ip) @@ -219,8 +236,6 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri userContext.LDAP.Groups = user.Groups if search.Name != "" { userContext.LDAP.Name = search.Name - } else { - userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username) } userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain) @@ -236,17 +251,29 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri } if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) { - m.auth.DeleteSession(ctx, uuid) + err := m.session.Delete(ctx, uuid) + if err != nil { + return nil, nil, fmt.Errorf("error deleting session: %w", err) + } return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email) } + + default: + return nil, nil, fmt.Errorf("unknown provider type: %s", userContext.Provider) } - cookie, err := m.auth.RefreshSession(ctx, uuid) + refreshed, err := m.session.Refresh(ctx, uuid) if err != nil { return nil, nil, fmt.Errorf("error refreshing session: %w", err) } + cookie := m.auth.CreateCookie(service.CreateCookieParams{ + Name: m.runtime.SessionCookieName, + Value: refreshed.UUID, + ExpiresAt: time.Unix(session.Session.ExpiresAt, 0), + }) + return userContext, cookie, nil } @@ -314,6 +341,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. return nil, nil, fmt.Errorf("user not found locally: %s", username) } + userContext.Provider = model.ProviderLocal userContext.Local = &model.LocalContext{ BaseContext: model.BaseContext{ Username: user.Username, @@ -322,7 +350,15 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. }, Attributes: user.Attributes, } - userContext.Provider = model.ProviderLocal + + if userContext.Local.Attributes.Name != "" { + userContext.Local.Name = userContext.Local.Attributes.Name + } + + if userContext.Local.Attributes.Email != "" { + userContext.Local.Email = userContext.Local.Attributes.Email + } + case model.UserLDAP: user, err := m.auth.GetLDAPUser(search.Username) @@ -334,6 +370,8 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. if name == "" { name = utils.Capitalize(username) } + + userContext.Provider = model.ProviderLDAP userContext.LDAP = &model.LDAPContext{ BaseContext: model.BaseContext{ Username: username, @@ -341,7 +379,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. }, Groups: user.Groups, } - userContext.Provider = model.ProviderLDAP userContext.LDAP.Email = utils.CompileUserEmail(username, m.runtime.CookieDomain) if search.Email != "" { diff --git a/internal/model/context.go b/internal/model/context.go index f95d715af..a8a2e2976 100644 --- a/internal/model/context.go +++ b/internal/model/context.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/tinyauthapp/tinyauth/internal/repository" ) var ( @@ -67,9 +66,10 @@ type UserContext struct { } type BaseContext struct { - Username string - Name string - Email string + DistinctUserID string + Username string + Name string + Email string } type LocalContext struct { @@ -141,61 +141,63 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) { return c, nil } -// Compatability layer until we get an excuse to drop in database migrations -func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) { +func (c *UserContext) NewFromSession(session SessionGetResult, identity IdentityResult) (*UserContext, error) { *c = UserContext{ - Authenticated: !session.TotpPending, - AuthTime: session.CreatedAt, + Authenticated: !session.SessionContext.Local.TOTPPending, + AuthTime: session.Session.CreatedAt, } - switch session.Provider { - case "local": + switch identity.Provider { + case ProviderLocal: c.Provider = ProviderLocal c.Local = &LocalContext{ BaseContext: BaseContext{ - Username: session.Username, - Name: session.Name, - Email: session.Email, + DistinctUserID: identity.ProviderUserID, + Username: identity.Username, + Name: identity.Name, + Email: identity.Email, }, - TOTPPending: session.TotpPending, + TOTPPending: session.SessionContext.Local.TOTPPending, } - case "ldap": + case ProviderLDAP: c.Provider = ProviderLDAP c.LDAP = &LDAPContext{ BaseContext: BaseContext{ - Username: session.Username, - Name: session.Name, - Email: session.Email, + DistinctUserID: identity.ProviderUserID, + Username: identity.Username, + Name: identity.Name, + Email: identity.Email, }, } - case "tailscale": + case ProviderTailscale: c.Provider = ProviderTailscale c.Tailscale = &TailscaleContext{ BaseContext: BaseContext{ - Username: session.Username, - Name: session.Name, - Email: session.Email, + DistinctUserID: identity.ProviderUserID, + Username: identity.Username, + Name: identity.Name, + Email: identity.Email, }, } - // By default we assume an unknown name which is oauth - default: + case ProviderOAuth: c.Provider = ProviderOAuth c.OAuth = &OAuthContext{ BaseContext: BaseContext{ - Username: session.Username, - Name: session.Name, - Email: session.Email, + DistinctUserID: identity.ProviderUserID, + Username: identity.Username, + Name: identity.Name, + Email: identity.Email, }, Groups: func() []string { - if session.OAuthGroups == "" { + if identity.IdentityContext.OAuth.Groups == "" { return nil } - return strings.Split(session.OAuthGroups, ",") + return strings.Split(identity.IdentityContext.OAuth.Groups, ",") }(), - Sub: session.OAuthSub, - DisplayName: session.OAuthName, - ID: session.Provider, + DisplayName: identity.IdentityContext.OAuth.DisplayName, } + default: + return nil, errors.New("unknown provider") } return c, nil diff --git a/internal/model/runtime.go b/internal/model/runtime.go index ad8ec8fda..ed5a398a7 100644 --- a/internal/model/runtime.go +++ b/internal/model/runtime.go @@ -1,5 +1,7 @@ package model +import "github.com/tinyauthapp/tinyauth/internal/repository" + type RuntimeConfig struct { AppURL string UUID string @@ -18,3 +20,67 @@ type Provider struct { ID string `json:"id"` OAuth bool `json:"oauth"` } + +type LinkingMode string + +const ( + LinkingModeEmail LinkingMode = "email" + LinkingModeUsername LinkingMode = "username" +) + +func (l LinkingMode) String() string { + return string(l) +} + +type IdentityResult struct { + // User information + Username string + Email string + Name string + + // Identity information + Provider ProviderType + ProviderUserID string + + // Context information + IdentityContext IdentityContext +} + +type IdentityLocalContext struct { + UserID string `json:"user_id"` + Attributes UserAttributes `json:"attributes"` +} + +type IdentityOAuthContext struct { + Groups string `json:"groups"` + DisplayName string `json:"display_name"` +} + +type IdentityLDAPContext struct { + Groups []string `json:"groups"` + DN string `json:"dn"` +} + +type IdentityTailscaleContext struct { + NodeName string `json:"node_name"` +} + +type IdentityContext struct { + Local IdentityLocalContext `json:"local"` + OAuth IdentityOAuthContext `json:"oauth"` + LDAP IdentityLDAPContext `json:"ldap"` + Tailscale IdentityTailscaleContext `json:"tailscale"` +} + +type SessionLocalContext struct { + TOTPPending bool `json:"totp_pending"` +} + +type SessionContext struct { + Local SessionLocalContext `json:"local"` +} + +type SessionGetResult struct { + Session repository.Session + SessionContext SessionContext +} diff --git a/internal/service/identity_service.go b/internal/service/identity_service.go index 7c742b6c9..e021deea7 100644 --- a/internal/service/identity_service.go +++ b/internal/service/identity_service.go @@ -13,42 +13,6 @@ import ( "go.uber.org/dig" ) -type LinkingMode string - -const ( - LinkingModeEmail LinkingMode = "email" - LinkingModeUsername LinkingMode = "username" -) - -type IdentityLocalContext struct { - Attributes model.UserAttributes `json:"attributes"` -} - -type IdentityOAuthContext struct { - Groups string `json:"groups"` - Sub string `json:"sub"` - DisplayName string `json:"display_name"` -} - -type IdentityLDAPContext struct { - Groups []string `json:"groups"` -} - -type IdentityTailscaleContext struct { - NodeName string `json:"node_name"` -} - -type IdentityContext struct { - Local IdentityLocalContext `json:"local"` - OAuth IdentityOAuthContext `json:"oauth"` - LDAP IdentityLDAPContext `json:"ldap"` - Tailscale IdentityTailscaleContext `json:"tailscale"` -} - -func (l LinkingMode) String() string { - return string(l) -} - type IdentityServiceInput struct { dig.In @@ -60,7 +24,7 @@ type IdentityService struct { store repository.Store runtime *model.RuntimeConfig - linkingMode LinkingMode + linkingMode model.LinkingMode } func NewIdentityService(i IdentityServiceInput) *IdentityService { @@ -78,11 +42,11 @@ func (s *IdentityService) getUserFunc(args repository.CreateUserParams) func(ctx } switch s.linkingMode { - case LinkingModeEmail: + case model.LinkingModeEmail: return func(ctx context.Context) (user repository.User, err error) { return s.store.GetUserByEmail(ctx, args.Email) } - case LinkingModeUsername: + case model.LinkingModeUsername: return func(ctx context.Context) (user repository.User, err error) { return s.store.GetUserByUsername(ctx, args.Username) } @@ -175,7 +139,7 @@ type StoreIdentityParams struct { ProviderUserID string // Context information - IdentityContext IdentityContext + IdentityContext model.IdentityContext } func (s *IdentityService) Store(ctx context.Context, params StoreIdentityParams) (identity repository.Identity, err error) { @@ -218,50 +182,36 @@ func (s *IdentityService) Store(ctx context.Context, params StoreIdentityParams) return identity, nil } -type IdentityResult struct { - // User information - Username string - Email string - Name string - - // Identity information - Provider model.ProviderType - ProviderUserID string - - // Context information - IdentityContext IdentityContext -} - -func (s *IdentityService) Load(ctx context.Context, uuid string) (result IdentityResult, err error) { +func (s *IdentityService) Load(ctx context.Context, uuid string) (result model.IdentityResult, err error) { identity, err := s.store.GetIdentityByUUID(ctx, uuid) if err != nil { - return IdentityResult{}, fmt.Errorf("failed to get identity: %w", err) + return model.IdentityResult{}, fmt.Errorf("failed to get identity: %w", err) } user, err := s.store.GetUserByUUID(ctx, identity.UserUUID) if err != nil { - return IdentityResult{}, fmt.Errorf("failed to get user: %w", err) + return model.IdentityResult{}, fmt.Errorf("failed to get user: %w", err) } - var identityContext IdentityContext + var identityContext model.IdentityContext if identity.ContextJSON != "" { err = json.Unmarshal([]byte(identity.ContextJSON), &identityContext) if err != nil { - return IdentityResult{}, fmt.Errorf("failed to unmarshal context: %w", err) + return model.IdentityResult{}, fmt.Errorf("failed to unmarshal context: %w", err) } } provider, err := model.ProviderFromString(identity.Provider) if err != nil { - return IdentityResult{}, fmt.Errorf("failed to parse provider: %w", err) + return model.IdentityResult{}, fmt.Errorf("failed to parse provider: %w", err) } - return IdentityResult{ + return model.IdentityResult{ Username: user.Username, Email: user.Email, Name: user.Name, diff --git a/internal/service/sessions_service.go b/internal/service/sessions_service.go index c9b4355a7..188497667 100644 --- a/internal/service/sessions_service.go +++ b/internal/service/sessions_service.go @@ -18,14 +18,6 @@ var ( ErrSessionNotFound = fmt.Errorf("session not found") ) -type SessionLocalContext struct { - TOTPPending bool `json:"totp_pending"` -} - -type SessionContext struct { - Local SessionLocalContext `json:"local"` -} - type SessionsService struct { identity *IdentityService @@ -52,7 +44,7 @@ func NewSessionsService(i SessionsServiceInput) *SessionsService { } } -func (s *SessionsService) Create(ctx context.Context, identityUUID string, sessionContext SessionContext) (repository.Session, error) { +func (s *SessionsService) Create(ctx context.Context, identityUUID string, sessionContext model.SessionContext) (repository.Session, error) { // validate that the identity exists (we use map[string]any because we know the ctx is json) _, err := s.identity.Load[map[string]any](ctx, identityUUID) @@ -141,37 +133,31 @@ func (s *SessionsService) Delete(ctx context.Context, uuid string) error { return nil } - -type SessionGetResult struct { - Session repository.Session - SessionContext SessionContext -} - -func (s *SessionsService) Get(ctx context.Context, uuid string) (SessionGetResult, error) { +func (s *SessionsService) Get(ctx context.Context, uuid string) (model.SessionGetResult, error) { session, err := s.store.GetSessionByUUID(ctx, uuid) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return SessionGetResult{}, ErrSessionNotFound + return model.SessionGetResult{}, ErrSessionNotFound } - return SessionGetResult{}, fmt.Errorf("failed to get session: %w", err) + return model.SessionGetResult{}, fmt.Errorf("failed to get session: %w", err) } - var sessionContext SessionContext + var sessionContext model.SessionContext err = json.Unmarshal([]byte(session.ContextJSON), &sessionContext) if err != nil { - return SessionGetResult{}, fmt.Errorf("failed to unmarshal session context: %w", err) + return model.SessionGetResult{}, fmt.Errorf("failed to unmarshal session context: %w", err) } - return SessionGetResult{ + return model.SessionGetResult{ Session: session, SessionContext: sessionContext, }, nil } -func (s *SessionsService) Update(ctx context.Context, uuid string, sessionContext SessionContext) (repository.Session, error) { +func (s *SessionsService) Update(ctx context.Context, uuid string, sessionContext model.SessionContext) (repository.Session, error) { session, err := s.store.GetSessionByUUID(ctx, uuid) if err != nil { From e79431e1c928dcbae17ac7600660a1b878544711 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 6 Nov 2026 23:56:18 +0200 Subject: [PATCH 05/16] refactor: adapt user controller to new services --- internal/bootstrap/app_bootstrap.go | 2 +- internal/bootstrap/db_bootstrap.go | 3 +- internal/controller/user_controller.go | 237 +++++++++++++--------- internal/middleware/context_middleware.go | 17 +- internal/model/constants.go | 2 +- internal/model/context.go | 8 + internal/model/runtime.go | 2 - internal/service/tailscale_service.go | 3 + 8 files changed, 165 insertions(+), 109 deletions(-) diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index eca79ebb6..e543bd63a 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -180,7 +180,7 @@ func (app *BootstrapApp) Setup() error { 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.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId) + app.runtime.OAuthFlowCookieName = fmt.Sprintf("%s-%s", model.OAuthFlowCookieName, cookieId) // database store, err := app.SetupStore() diff --git a/internal/bootstrap/db_bootstrap.go b/internal/bootstrap/db_bootstrap.go index 67d6549ab..13856406d 100644 --- a/internal/bootstrap/db_bootstrap.go +++ b/internal/bootstrap/db_bootstrap.go @@ -15,7 +15,6 @@ import ( "github.com/tinyauthapp/tinyauth/internal/assets" "github.com/tinyauthapp/tinyauth/internal/repository" - "github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/postgres" "github.com/tinyauthapp/tinyauth/internal/repository/sqlite" ) @@ -23,7 +22,7 @@ import ( func (app *BootstrapApp) SetupStore() (repository.Store, error) { switch app.config.Database.Driver { case "memory": - return memory.New(), nil + return app.setupSQLite(":memory:") case "sqlite", "": return app.setupSQLite(app.config.Database.Path) case "postgres": diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 4dc9ec1aa..6b2cbf69c 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -7,7 +7,6 @@ import ( "time" "github.com/tinyauthapp/tinyauth/internal/model" - "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" @@ -28,9 +27,11 @@ type TotpRequest struct { } type UserController struct { - log *logger.Logger - runtime *model.RuntimeConfig - auth *service.AuthService + log *logger.Logger + runtime *model.RuntimeConfig + auth *service.AuthService + identity *service.IdentityService + sessions *service.SessionsService } type UserControllerInput struct { @@ -40,13 +41,17 @@ type UserControllerInput struct { RuntimeConfig *model.RuntimeConfig RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` AuthService *service.AuthService + Identity *service.IdentityService + Sessions *service.SessionsService } func NewUserController(i UserControllerInput) *UserController { controller := &UserController{ - log: i.Log, - runtime: i.RuntimeConfig, - auth: i.AuthService, + log: i.Log, + runtime: i.RuntimeConfig, + auth: i.AuthService, + identity: i.Identity, + sessions: i.Sessions, } userGroup := i.RouterGroup.Group("/user") @@ -129,13 +134,15 @@ func (controller *UserController) loginHandler(c *gin.Context) { return } - var localUser *model.LocalUser + var identityParams service.StoreIdentityParams + var sessionContext model.SessionContext - if search.Type == model.UserLocal { - localUser = controller.auth.GetLocalUser(req.Username) + switch search.Type { + case model.UserLocal: + user := controller.auth.GetLocalUser(search.Username) - if localUser == nil { - controller.log.App.Error().Str("username", req.Username).Msg("Local user not found after successful password verification") + if user == nil { + controller.log.App.Error().Str("username", search.Username).Msg("Local user not found after successful password verification") c.JSON(401, gin.H{ "status": 401, "message": "Unauthorized", @@ -143,77 +150,84 @@ func (controller *UserController) loginHandler(c *gin.Context) { return } - if localUser.TOTPSecret != "" { - controller.log.App.Debug().Str("username", req.Username).Msg("TOTP required for user, creating pending TOTP session") - - name := localUser.Attributes.Name - if name == "" { - name = utils.Capitalize(localUser.Username) - } - - email := localUser.Attributes.Email - if email == "" { - email = utils.CompileUserEmail(localUser.Username, controller.runtime.CookieDomain) - } + if user.TOTPSecret != "" { + sessionContext.Local.TOTPPending = true + } - cookie, err := controller.auth.CreateSession(c, repository.Session{ - Username: localUser.Username, - Name: name, - Email: email, - Provider: "local", - TotpPending: true, - }) + name := utils.Capitalize(user.Username) + if user.Attributes.Name != "" { + name = user.Attributes.Name + } - if err != nil { - controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session") - c.JSON(500, gin.H{ - "status": 500, - "message": "Internal Server Error", - }) - return - } + email := utils.CompileUserEmail(user.Username, controller.runtime.CookieDomain) + if user.Attributes.Email != "" { + email = user.Attributes.Email + } - http.SetCookie(c.Writer, cookie) + identityParams = service.StoreIdentityParams{ + Username: user.Username, + Email: email, + Name: name, + ProviderID: "local", + ProviderType: model.ProviderLocal, + ProviderUserID: "local:" + user.Username, + IdentityContext: model.IdentityContext{ + Local: model.IdentityLocalContext{ + Attributes: user.Attributes, + }, + }, + } + case model.UserLDAP: + user, err := controller.auth.GetLDAPUser(search.Username) - c.JSON(200, gin.H{ - "status": 200, - "message": "TOTP required", - "totpPending": true, + if err != nil { + controller.log.App.Error().Err(err).Str("username", search.Username).Msg("Error retrieving LDAP user after successful password verification") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", }) return } - } - - sessionCookie := repository.Session{ - Username: req.Username, - Name: utils.Capitalize(req.Username), - Email: utils.CompileUserEmail(req.Username, controller.runtime.CookieDomain), - Provider: "local", - } - if search.Type == model.UserLocal { - if localUser.Attributes.Name != "" { - sessionCookie.Name = localUser.Attributes.Name + if user == nil { + controller.log.App.Error().Str("username", search.Username).Msg("LDAP user not found after successful password verification") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return } - if localUser.Attributes.Email != "" { - sessionCookie.Email = localUser.Attributes.Email + + identityParams = service.StoreIdentityParams{ + Username: req.Username, + Email: search.Email, + Name: search.Name, + ProviderID: "ldap", + ProviderType: model.ProviderLDAP, + ProviderUserID: user.DN, + IdentityContext: model.IdentityContext{ + LDAP: model.IdentityLDAPContext{ + Groups: user.Groups, + }, + }, } } - if search.Type == model.UserLDAP { - sessionCookie.Provider = "ldap" - if search.Email != "" { - sessionCookie.Email = search.Email - } - if search.Name != "" { - sessionCookie.Name = search.Name - } + identity, err := controller.identity.Store(c, identityParams) + + if err != nil { + controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error storing identity after successful login") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return } - cookie, err := controller.auth.CreateSession(c, sessionCookie) + session, err := controller.sessions.Create(c, identity.UUID, sessionContext) if err != nil { - controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login") + controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error creating session after successful login") c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -221,6 +235,12 @@ func (controller *UserController) loginHandler(c *gin.Context) { return } + cookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.SessionCookieName, + Value: session.UUID, + ExpiresAt: time.Unix(session.ExpiresAt, 0), + }) + http.SetCookie(c.Writer, cookie) controller.log.App.Info().Str("username", req.Username).Msg("Login successful") @@ -260,7 +280,7 @@ func (controller *UserController) logoutHandler(c *gin.Context) { return } - cookie, err := controller.auth.DeleteSession(c, uuid) + err = controller.sessions.Delete(c, uuid) if err != nil { controller.log.App.Error().Err(err).Msg("Error deleting session on logout") @@ -280,6 +300,12 @@ func (controller *UserController) logoutHandler(c *gin.Context) { controller.log.AuditLogout("unknown", "unknown", c.ClientIP()) } + cookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.SessionCookieName, + Value: "", + ExpiresAt: time.Now().Add(-time.Hour), + }) + http.SetCookie(c.Writer, cookie) c.JSON(200, gin.H{ @@ -301,6 +327,17 @@ func (controller *UserController) totpHandler(c *gin.Context) { return } + uuid, err := c.Cookie(controller.runtime.SessionCookieName) + + if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + context, err := new(model.UserContext).NewFromGin(c) if err != nil { @@ -372,10 +409,8 @@ func (controller *UserController) totpHandler(c *gin.Context) { return fmt.Errorf("invalid TOTP code") } - uuid, err := c.Cookie(controller.runtime.SessionCookieName) - if err == nil { - _, err = controller.auth.DeleteSession(c, uuid) + err = controller.sessions.Delete(c, uuid) if err != nil { controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification") } @@ -392,24 +427,14 @@ func (controller *UserController) totpHandler(c *gin.Context) { return } - sessionCookie := repository.Session{ - Username: user.Username, - Name: utils.Capitalize(user.Username), - Email: utils.CompileUserEmail(user.Username, controller.runtime.CookieDomain), - Provider: "local", - } - - if user.Attributes.Name != "" { - sessionCookie.Name = user.Attributes.Name - } - if user.Attributes.Email != "" { - sessionCookie.Email = user.Attributes.Email - } - - cookie, err := controller.auth.CreateSession(c, sessionCookie) + _, err = controller.sessions.Update(c, uuid, model.SessionContext{ + Local: model.SessionLocalContext{ + TOTPPending: false, + }, + }) if err != nil { - controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification") + controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to update pending TOTP session after successful verification") c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -417,8 +442,6 @@ func (controller *UserController) totpHandler(c *gin.Context) { return } - http.SetCookie(c.Writer, cookie) - controller.log.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful, login complete") controller.log.AuditLoginSuccess(context.GetUsername(), "local", c.ClientIP()) @@ -457,17 +480,33 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) { return } - sessionCookie := repository.Session{ - Username: context.Tailscale.Username, - Name: context.Tailscale.Name, - Email: context.Tailscale.Email, - Provider: "tailscale", + identity, err := controller.identity.Store(c, service.StoreIdentityParams{ + Username: context.Tailscale.Username, + Email: context.Tailscale.Email, + Name: context.Tailscale.Name, + ProviderID: "tailscale", + ProviderType: model.ProviderTailscale, + ProviderUserID: context.GetDistinctUserID(), + IdentityContext: model.IdentityContext{ + Tailscale: model.IdentityTailscaleContext{ + NodeName: context.Tailscale.NodeName, + }, + }, + }) + + if err != nil { + controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to store Tailscale identity after successful login") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return } - cookie, err := controller.auth.CreateSession(c, sessionCookie) + session, err := controller.sessions.Create(c, identity.UUID, model.SessionContext{}) if err != nil { - controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login") + controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create Tailscale session after successful login") c.JSON(500, gin.H{ "status": 500, "message": "Internal Server Error", @@ -475,6 +514,12 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) { return } + cookie := controller.auth.CreateCookie(service.CreateCookieParams{ + Name: controller.runtime.SessionCookieName, + Value: session.UUID, + ExpiresAt: time.Unix(session.ExpiresAt, 0), + }) + http.SetCookie(c.Writer, cookie) controller.log.App.Info().Str("username", context.GetUsername()).Msg("Tailscale login successful, login complete") diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 4cb3a9098..fdc36104c 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -344,9 +344,10 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. userContext.Provider = model.ProviderLocal userContext.Local = &model.LocalContext{ BaseContext: model.BaseContext{ - Username: user.Username, - Name: utils.Capitalize(user.Username), - Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain), + DistinctUserID: "local:" + user.Username, + Username: user.Username, + Name: utils.Capitalize(user.Username), + Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain), }, Attributes: user.Attributes, } @@ -374,8 +375,9 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. userContext.Provider = model.ProviderLDAP userContext.LDAP = &model.LDAPContext{ BaseContext: model.BaseContext{ - Username: username, - Name: name, + DistinctUserID: search.Username, + Username: username, + Name: name, }, Groups: user.Groups, } @@ -417,8 +419,9 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, uctx := model.TailscaleContext{ BaseContext: model.BaseContext{ - Email: whois.LoginName, - Name: whois.DisplayName, + DistinctUserID: whois.UserID, + Email: whois.LoginName, + Name: whois.DisplayName, }, NodeName: whois.NodeName, } diff --git a/internal/model/constants.go b/internal/model/constants.go index fa41896a6..41af3c36e 100644 --- a/internal/model/constants.go +++ b/internal/model/constants.go @@ -21,6 +21,6 @@ var OverrideProviders = map[string]string{ var ReservedProviderNames = []string{"local", "ldap", "tailscale"} const SessionCookieName = "tinyauth-session" -const OAuthSessionCookieName = "tinyauth-oauth" +const OAuthFlowCookieName = "tinyauth-oauth" const GracefulShutdownTimeout = 5 // seconds diff --git a/internal/model/context.go b/internal/model/context.go index a8a2e2976..dd49fe905 100644 --- a/internal/model/context.go +++ b/internal/model/context.go @@ -230,6 +230,14 @@ func (c *UserContext) getBaseContext() *BaseContext { } } +func (c *UserContext) GetDistinctUserID() string { + base := c.getBaseContext() + if base == nil { + return "" + } + return base.DistinctUserID +} + func (c *UserContext) GetUsername() string { base := c.getBaseContext() if base == nil { diff --git a/internal/model/runtime.go b/internal/model/runtime.go index ed5a398a7..28881e95a 100644 --- a/internal/model/runtime.go +++ b/internal/model/runtime.go @@ -47,7 +47,6 @@ type IdentityResult struct { } type IdentityLocalContext struct { - UserID string `json:"user_id"` Attributes UserAttributes `json:"attributes"` } @@ -58,7 +57,6 @@ type IdentityOAuthContext struct { type IdentityLDAPContext struct { Groups []string `json:"groups"` - DN string `json:"dn"` } type IdentityTailscaleContext struct { diff --git a/internal/service/tailscale_service.go b/internal/service/tailscale_service.go index 68700ac63..02c7da4de 100644 --- a/internal/service/tailscale_service.go +++ b/internal/service/tailscale_service.go @@ -37,6 +37,7 @@ type tailscaleAPIDevices struct { } type tailscaleUser struct { + ID string `json:"id"` DisplayName string `json:"displayName"` LoginName string `json:"loginName"` } @@ -46,6 +47,7 @@ type tailscaleAPIUsers struct { } type TailscaleWhoisResponse struct { + UserID string DisplayName string LoginName string NodeName string @@ -238,6 +240,7 @@ func (s *TailscaleService) Whois(addr string) (*TailscaleWhoisResponse, error) { s.log.App.Debug().Str("user", user.LoginName).Bool("cache_hit", uCacheHit).Msg("Tailscale user found") return &TailscaleWhoisResponse{ + UserID: user.ID, LoginName: user.LoginName, DisplayName: user.DisplayName, NodeName: device.Name, From 6654fe4cc58e952c237c901d10df8c49c30faafa Mon Sep 17 00:00:00 2001 From: Stavros Date: Tue, 10 Nov 2026 22:41:42 +0200 Subject: [PATCH 06/16] wip: working prototype --- Dockerfile.dev | 2 +- .../src/components/domain-warning/domain-warning.tsx | 4 ++-- internal/controller/proxy_controller.go | 2 +- internal/middleware/context_middleware.go | 10 +++++----- internal/model/context.go | 8 ++++---- internal/model/runtime.go | 3 ++- internal/service/access_controls_rules.go | 4 ++-- internal/service/identity_service.go | 7 +++++-- internal/service/sessions_service.go | 4 ++-- 9 files changed, 24 insertions(+), 20 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index d4a9c3861..ff58ec098 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM golang:1.26-alpine3.23 +FROM golang:1.27-alpine3.23 WORKDIR /tinyauth diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx index d8e9eed92..c90f64bff 100644 --- a/frontend/src/components/domain-warning/domain-warning.tsx +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -20,7 +20,7 @@ export const DomainWarning = (props: Props) => { const { t } = useTranslation(); const { search } = useLocation(); - const searchParams = new URLSearchParams(search); + const searchParams = (new URLSearchParams(search)).toString(); return ( @@ -45,7 +45,7 @@ export const DomainWarning = (props: Props) => {