detached 6aced72

Merge pull request #41 from bnema/feature/auth-status

feat(cli): add auth status command to verify session validity
commit
6aced7291756ece026ab8f3c570f69157429b2bc
author
bnema · · 2026-01-29 04:54 UTC
committer
GitHub · · 2026-01-29 04:54 UTC
merge of
ed1cab5 + 529bf12 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.8.0
27 files · +360 -44
M .mockery.yaml +2 -2
@@ -7,7 +7,7 @@ structname: 'Mock{{.InterfaceName}}'
77 pkgname: 'mocks'
88
99 packages:
10- gordon/internal/boundaries/out:
10+ github.com/bnema/gordon/internal/boundaries/out:
1111 interfaces:
1212 ContainerRuntime:
1313 BlobStorage:
@@ -22,7 +22,7 @@ packages:
2222 TokenStore:
2323 DomainSecretStore:
2424 RateLimiter:
25- gordon/internal/boundaries/in:
25+ github.com/bnema/gordon/internal/boundaries/in:
2626 interfaces:
2727 ContainerService:
2828 RegistryService:
@@ -7,7 +7,7 @@ structname: 'Mock{{.InterfaceName}}'
7 pkgname: 'mocks'7 pkgname: 'mocks'
8 8
9 packages:9 packages:
10- gordon/internal/boundaries/out:10+ github.com/bnema/gordon/internal/boundaries/out:
11 interfaces:11 interfaces:
12 ContainerRuntime:12 ContainerRuntime:
13 BlobStorage:13 BlobStorage:
@@ -22,7 +22,7 @@ packages:
22 TokenStore:22 TokenStore:
23 DomainSecretStore:23 DomainSecretStore:
24 RateLimiter:24 RateLimiter:
25- gordon/internal/boundaries/in:25+ github.com/bnema/gordon/internal/boundaries/in:
26 interfaces:26 interfaces:
27 ContainerService:27 ContainerService:
28 RegistryService:28 RegistryService:
A internal/adapters/dto/admin_auth.go +10 -0
@@ -0,0 +1,10 @@
1+package dto
2+
3+// AuthVerifyResponse represents response from /admin/auth/verify.
4+type AuthVerifyResponse struct {
5+ Valid bool `json:"valid"`
6+ Subject string `json:"subject,omitempty"`
7+ Scopes []string `json:"scopes,omitempty"`
8+ ExpiresAt int64 `json:"expires_at,omitempty"`
9+ IssuedAt int64 `json:"issued_at,omitempty"`
10+}
@@ -0,0 +1,10 @@
1+package dto
2+
3+// AuthVerifyResponse represents response from /admin/auth/verify.
4+type AuthVerifyResponse struct {
5+ Valid bool `json:"valid"`
6+ Subject string `json:"subject,omitempty"`
7+ Scopes []string `json:"scopes,omitempty"`
8+ ExpiresAt int64 `json:"expires_at,omitempty"`
9+ IssuedAt int64 `json:"issued_at,omitempty"`
10+}
M internal/adapters/in/cli/auth.go +144 -0
@@ -13,6 +13,7 @@ import (
1313 "github.com/spf13/viper"
1414 "golang.org/x/term"
1515
16+ "github.com/bnema/gordon/internal/adapters/dto"
1617 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1718 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1819 "github.com/bnema/gordon/internal/adapters/out/secrets"
@@ -35,6 +36,7 @@ func newAuthCmd() *cobra.Command {
3536 cmd.AddCommand(newAuthPasswordCmd())
3637 cmd.AddCommand(newAuthInternalCmd())
3738 cmd.AddCommand(newAuthLoginCmd())
39+ cmd.AddCommand(newAuthStatusCmd())
3840
3941 return cmd
4042 }
@@ -651,3 +653,145 @@ func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service
651653
652654 return auth.NewService(authConfig, store, log), nil
653655 }
656+
657+// newAuthStatusCmd creates the auth status command.
658+func newAuthStatusCmd() *cobra.Command {
659+ var remoteNameFlag string
660+ cmd := &cobra.Command{
661+ Use: "status",
662+ Short: "Check authentication session status",
663+ Long: `Verify if stored authentication session is still valid.
664+
665+Shows token validity, expiry, and connection status for the active remote.
666+
667+Examples:
668+ gordon auth status Check status of active remote
669+ gordon auth status --remote prod Check specific remote`,
670+ RunE: func(cmd *cobra.Command, args []string) error {
671+ return runAuthStatus(remoteNameFlag)
672+ },
673+ }
674+ cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
675+ return cmd
676+}
677+
678+// runAuthStatus checks authentication status for the active remote.
679+func runAuthStatus(remoteNameArg string) error {
680+ // Load remotes config
681+ config, err := remote.LoadRemotes("")
682+ if err != nil {
683+ return fmt.Errorf("failed to load remotes: %w", err)
684+ }
685+
686+ // Determine remote name
687+ remoteName := remoteNameArg
688+ if remoteName == "" {
689+ remoteName = config.Active
690+ }
691+ if remoteName == "" {
692+ return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
693+ }
694+
695+ remoteConfig, ok := config.Remotes[remoteName]
696+ if !ok {
697+ if remoteNameArg != "" {
698+ return fmt.Errorf("remote '%s' not found", remoteName)
699+ }
700+ return fmt.Errorf("active remote '%s' not found", remoteName)
701+ }
702+
703+ // Create client
704+ client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token))
705+
706+ // Verify auth
707+ ctx := context.Background()
708+ fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
709+
710+ status, err := client.VerifyAuth(ctx)
711+ if err != nil {
712+ return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
713+ }
714+
715+ // Display status
716+ displayAuthStatus(remoteName, remoteConfig.URL, status)
717+ return nil
718+}
719+
720+// handleAuthVerifyError handles errors from auth verification.
721+func handleAuthVerifyError(err error, remoteName, url string) error {
722+ errStrLower := strings.ToLower(err.Error())
723+
724+ // Connection errors
725+ if strings.Contains(errStrLower, "connection refused") ||
726+ strings.Contains(errStrLower, "no such host") ||
727+ strings.Contains(errStrLower, "timeout") {
728+ fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
729+ fmt.Println()
730+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
731+ fmt.Printf("Error: %s\n\n", err.Error())
732+ fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
733+ return nil
734+ }
735+
736+ // Auth errors
737+ if strings.Contains(errStrLower, "401") ||
738+ strings.Contains(errStrLower, "unauthorized") ||
739+ strings.Contains(errStrLower, "invalid token") {
740+ fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
741+ fmt.Println()
742+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
743+ fmt.Println()
744+ fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
745+ fmt.Println()
746+ fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
747+ fmt.Printf(" gordon auth login --remote %s\n", remoteName)
748+ return nil
749+ }
750+
751+ return fmt.Errorf("failed to verify auth: %w", err)
752+}
753+
754+// displayAuthStatus displays authentication status information.
755+func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
756+ fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
757+ fmt.Println()
758+
759+ if status.Valid {
760+ // Valid authentication
761+ fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
762+ fmt.Println()
763+
764+ if status.Subject != "" {
765+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
766+ }
767+
768+ if status.IssuedAt > 0 {
769+ issuedAt := time.Unix(status.IssuedAt, 0)
770+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
771+ }
772+
773+ if status.ExpiresAt > 0 {
774+ expiresAt := time.Unix(status.ExpiresAt, 0)
775+ if expiresAt.Before(time.Now()) {
776+ fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
777+ expiresAt.Format("2006-01-02 15:04:05"),
778+ styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
779+ } else {
780+ remaining := time.Until(expiresAt)
781+ fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
782+ expiresAt.Format("2006-01-02 15:04:05"),
783+ styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
784+ }
785+ }
786+
787+ if len(status.Scopes) > 0 {
788+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
789+ }
790+ } else {
791+ // Invalid or no authentication
792+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
793+ fmt.Println()
794+ fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
795+ fmt.Printf(" gordon auth login --remote %s\n", remoteName)
796+ }
797+}
@@ -13,6 +13,7 @@ import (
13 "github.com/spf13/viper"13 "github.com/spf13/viper"
14 "golang.org/x/term"14 "golang.org/x/term"
15 15
16+ "github.com/bnema/gordon/internal/adapters/dto"
16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
18 "github.com/bnema/gordon/internal/adapters/out/secrets"19 "github.com/bnema/gordon/internal/adapters/out/secrets"
@@ -35,6 +36,7 @@ func newAuthCmd() *cobra.Command {
35 cmd.AddCommand(newAuthPasswordCmd())36 cmd.AddCommand(newAuthPasswordCmd())
36 cmd.AddCommand(newAuthInternalCmd())37 cmd.AddCommand(newAuthInternalCmd())
37 cmd.AddCommand(newAuthLoginCmd())38 cmd.AddCommand(newAuthLoginCmd())
39+ cmd.AddCommand(newAuthStatusCmd())
38 40
39 return cmd41 return cmd
40 }42 }
@@ -651,3 +653,145 @@ func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service
651 653
652 return auth.NewService(authConfig, store, log), nil654 return auth.NewService(authConfig, store, log), nil
653 }655 }
656+
657+// newAuthStatusCmd creates the auth status command.
658+func newAuthStatusCmd() *cobra.Command {
659+ var remoteNameFlag string
660+ cmd := &cobra.Command{
661+ Use: "status",
662+ Short: "Check authentication session status",
663+ Long: `Verify if stored authentication session is still valid.
664+
665+Shows token validity, expiry, and connection status for the active remote.
666+
667+Examples:
668+ gordon auth status Check status of active remote
669+ gordon auth status --remote prod Check specific remote`,
670+ RunE: func(cmd *cobra.Command, args []string) error {
671+ return runAuthStatus(remoteNameFlag)
672+ },
673+ }
674+ cmd.Flags().StringVarP(&remoteNameFlag, "remote", "r", "", "Check specific remote")
675+ return cmd
676+}
677+
678+// runAuthStatus checks authentication status for the active remote.
679+func runAuthStatus(remoteNameArg string) error {
680+ // Load remotes config
681+ config, err := remote.LoadRemotes("")
682+ if err != nil {
683+ return fmt.Errorf("failed to load remotes: %w", err)
684+ }
685+
686+ // Determine remote name
687+ remoteName := remoteNameArg
688+ if remoteName == "" {
689+ remoteName = config.Active
690+ }
691+ if remoteName == "" {
692+ return fmt.Errorf("no active remote configured. Use 'gordon remote use <name>' or --remote flag")
693+ }
694+
695+ remoteConfig, ok := config.Remotes[remoteName]
696+ if !ok {
697+ if remoteNameArg != "" {
698+ return fmt.Errorf("remote '%s' not found", remoteName)
699+ }
700+ return fmt.Errorf("active remote '%s' not found", remoteName)
701+ }
702+
703+ // Create client
704+ client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token))
705+
706+ // Verify auth
707+ ctx := context.Background()
708+ fmt.Printf("Checking authentication status for '%s'...\n", remoteName)
709+
710+ status, err := client.VerifyAuth(ctx)
711+ if err != nil {
712+ return handleAuthVerifyError(err, remoteName, remoteConfig.URL)
713+ }
714+
715+ // Display status
716+ displayAuthStatus(remoteName, remoteConfig.URL, status)
717+ return nil
718+}
719+
720+// handleAuthVerifyError handles errors from auth verification.
721+func handleAuthVerifyError(err error, remoteName, url string) error {
722+ errStrLower := strings.ToLower(err.Error())
723+
724+ // Connection errors
725+ if strings.Contains(errStrLower, "connection refused") ||
726+ strings.Contains(errStrLower, "no such host") ||
727+ strings.Contains(errStrLower, "timeout") {
728+ fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
729+ fmt.Println()
730+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Connection Failed"))
731+ fmt.Printf("Error: %s\n\n", err.Error())
732+ fmt.Println(styles.Theme.Muted.Render("Check that Gordon server is running and accessible."))
733+ return nil
734+ }
735+
736+ // Auth errors
737+ if strings.Contains(errStrLower, "401") ||
738+ strings.Contains(errStrLower, "unauthorized") ||
739+ strings.Contains(errStrLower, "invalid token") {
740+ fmt.Println(styles.Theme.Bold.Render("Remote: " + url))
741+ fmt.Println()
742+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Authentication Failed"))
743+ fmt.Println()
744+ fmt.Println(styles.Theme.Muted.Render("Reason: Token is invalid or expired"))
745+ fmt.Println()
746+ fmt.Println(styles.Theme.Bold.Render("Action: Re-authenticate with:"))
747+ fmt.Printf(" gordon auth login --remote %s\n", remoteName)
748+ return nil
749+ }
750+
751+ return fmt.Errorf("failed to verify auth: %w", err)
752+}
753+
754+// displayAuthStatus displays authentication status information.
755+func displayAuthStatus(remoteName, url string, status *dto.AuthVerifyResponse) {
756+ fmt.Println(styles.Theme.Bold.Render("Remote: " + remoteName + " | URL: " + url))
757+ fmt.Println()
758+
759+ if status.Valid {
760+ // Valid authentication
761+ fmt.Println(styles.Theme.Success.Render(styles.IconSuccess + " Authenticated"))
762+ fmt.Println()
763+
764+ if status.Subject != "" {
765+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Subject:"), status.Subject)
766+ }
767+
768+ if status.IssuedAt > 0 {
769+ issuedAt := time.Unix(status.IssuedAt, 0)
770+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Issued:"), issuedAt.Format("2006-01-02 15:04:05"))
771+ }
772+
773+ if status.ExpiresAt > 0 {
774+ expiresAt := time.Unix(status.ExpiresAt, 0)
775+ if expiresAt.Before(time.Now()) {
776+ fmt.Printf("%s %s (%s ago)\n", styles.Theme.Bold.Render("Expired:"),
777+ expiresAt.Format("2006-01-02 15:04:05"),
778+ styles.Theme.Muted.Render(time.Since(expiresAt).Round(time.Second).String()))
779+ } else {
780+ remaining := time.Until(expiresAt)
781+ fmt.Printf("%s %s (%s)\n", styles.Theme.Bold.Render("Expires:"),
782+ expiresAt.Format("2006-01-02 15:04:05"),
783+ styles.Theme.Muted.Render(remaining.Round(time.Second).String()))
784+ }
785+ }
786+
787+ if len(status.Scopes) > 0 {
788+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Scopes:"), strings.Join(status.Scopes, ", "))
789+ }
790+ } else {
791+ // Invalid or no authentication
792+ fmt.Println(styles.Theme.Error.Render(styles.IconError + " Not Authenticated"))
793+ fmt.Println()
794+ fmt.Println(styles.Theme.Muted.Render("Action: Re-authenticate with:"))
795+ fmt.Printf(" gordon auth login --remote %s\n", remoteName)
796+ }
797+}
M internal/adapters/in/cli/remote/client.go +15 -0
@@ -632,3 +632,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
632632
633633 return ch, nil
634634 }
635+
636+// VerifyAuth checks if authentication session is valid.
637+func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
638+ resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
639+ if err != nil {
640+ return nil, err
641+ }
642+
643+ var result dto.AuthVerifyResponse
644+ if err := parseResponse(resp, &result); err != nil {
645+ return nil, err
646+ }
647+
648+ return &result, nil
649+}
@@ -632,3 +632,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
632 632
633 return ch, nil633 return ch, nil
634 }634 }
635+
636+// VerifyAuth checks if authentication session is valid.
637+func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
638+ resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
639+ if err != nil {
640+ return nil, err
641+ }
642+
643+ var result dto.AuthVerifyResponse
644+ if err := parseResponse(resp, &result); err != nil {
645+ return nil, err
646+ }
647+
648+ return &result, nil
649+}
M internal/adapters/in/http/admin/handler.go +37 -5
@@ -140,11 +140,12 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
140140 func (h *Handler) matchRoute(path string) (routeHandler, bool) {
141141 // Exact match routes
142142 exactRoutes := map[string]routeHandler{
143- "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
144- "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
145- "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
146- "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
147- "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
143+ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
144+ "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
145+ "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
146+ "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
147+ "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
148+ "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
148149 }
149150 if handler, ok := exactRoutes[path]; ok {
150151 return handler, true
@@ -1078,3 +1079,34 @@ func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.R
10781079 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
10791080 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
10801081 }
1082+
1083+// handleAuthVerify handles /admin/auth/verify endpoint.
1084+// Validates authentication session and returns token status.
1085+func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1086+ ctx := r.Context()
1087+
1088+ // Only allow GET method
1089+ if r.Method != http.MethodGet {
1090+ w.Header().Set("Allow", http.MethodGet)
1091+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1092+ return
1093+ }
1094+
1095+ // Call use case to get auth status
1096+ status, err := h.authSvc.GetAuthStatus(ctx)
1097+ if err != nil {
1098+ h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1099+ return
1100+ }
1101+
1102+ // Convert domain.AuthStatus to DTO
1103+ response := dto.AuthVerifyResponse{
1104+ Valid: status.Valid,
1105+ Subject: status.Subject,
1106+ Scopes: status.Scopes,
1107+ ExpiresAt: status.ExpiresAt,
1108+ IssuedAt: status.IssuedAt,
1109+ }
1110+
1111+ h.sendJSON(w, http.StatusOK, response)
1112+}
@@ -140,11 +140,12 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
140 func (h *Handler) matchRoute(path string) (routeHandler, bool) {140 func (h *Handler) matchRoute(path string) (routeHandler, bool) {
141 // Exact match routes141 // Exact match routes
142 exactRoutes := map[string]routeHandler{142 exactRoutes := map[string]routeHandler{
143- "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },143+ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
144- "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },144+ "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
145- "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },145+ "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
146- "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },146+ "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
147- "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },147+ "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
148+ "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
148 }149 }
149 if handler, ok := exactRoutes[path]; ok {150 if handler, ok := exactRoutes[path]; ok {
150 return handler, true151 return handler, true
@@ -1078,3 +1079,34 @@ func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.R
1078 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")1079 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1079 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})1080 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1080 }1081 }
1082+
1083+// handleAuthVerify handles /admin/auth/verify endpoint.
1084+// Validates authentication session and returns token status.
1085+func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1086+ ctx := r.Context()
1087+
1088+ // Only allow GET method
1089+ if r.Method != http.MethodGet {
1090+ w.Header().Set("Allow", http.MethodGet)
1091+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1092+ return
1093+ }
1094+
1095+ // Call use case to get auth status
1096+ status, err := h.authSvc.GetAuthStatus(ctx)
1097+ if err != nil {
1098+ h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1099+ return
1100+ }
1101+
1102+ // Convert domain.AuthStatus to DTO
1103+ response := dto.AuthVerifyResponse{
1104+ Valid: status.Valid,
1105+ Subject: status.Subject,
1106+ Scopes: status.Scopes,
1107+ ExpiresAt: status.ExpiresAt,
1108+ IssuedAt: status.IssuedAt,
1109+ }
1110+
1111+ h.sendJSON(w, http.StatusOK, response)
1112+}
M internal/adapters/in/http/admin/handler_test.go +1 -1
@@ -23,7 +23,7 @@ func testLogger() zerowrap.Logger {
2323
2424 func ctxWithScopes(scopes ...string) context.Context {
2525 ctx := context.Background()
26- return context.WithValue(ctx, ContextKeyScopes, scopes)
26+ return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
2727 }
2828
2929 // Routes endpoint tests
@@ -23,7 +23,7 @@ func testLogger() zerowrap.Logger {
23 23
24 func ctxWithScopes(scopes ...string) context.Context {24 func ctxWithScopes(scopes ...string) context.Context {
25 ctx := context.Background()25 ctx := context.Background()
26- return context.WithValue(ctx, ContextKeyScopes, scopes)26+ return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
27 }27 }
28 28
29 // Routes endpoint tests29 // Routes endpoint tests
M internal/adapters/in/http/admin/middleware.go +6 -15
@@ -16,16 +16,6 @@ import (
1616 "github.com/bnema/gordon/internal/domain"
1717 )
1818
19-// contextKey is a custom type for context keys to avoid collisions.
20-type contextKey string
21-
22-const (
23- // ContextKeyScopes is the context key for storing token scopes.
24- ContextKeyScopes contextKey = "admin_scopes"
25- // ContextKeySubject is the context key for storing token subject.
26- ContextKeySubject contextKey = "admin_subject"
27-)
28-
2919 // AuthMiddleware creates middleware that validates admin API authentication.
3020 // Both globalLimiter and ipLimiter can be nil to disable rate limiting.
3121 // The trustedNets parameter is used for proper IP extraction behind reverse proxies.
@@ -110,8 +100,9 @@ func AuthMiddleware(
110100 }
111101
112102 // Add claims to context for downstream handlers
113- ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)
114- ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)
103+ ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
104+ ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
105+ ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
115106
116107 next.ServeHTTP(w, r.WithContext(ctx))
117108 })
@@ -125,7 +116,7 @@ func RequireScope(resource, action string) func(http.Handler) http.Handler {
125116 ctx := r.Context()
126117
127118 // Get scopes from context
128- scopes, ok := ctx.Value(ContextKeyScopes).([]string)
119+ scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
129120 if !ok {
130121 sendForbidden(w, "no scopes in context")
131122 return
@@ -144,13 +135,13 @@ func RequireScope(resource, action string) func(http.Handler) http.Handler {
144135
145136 // GetSubject retrieves the authenticated subject from the context.
146137 func GetSubject(ctx context.Context) string {
147- subject, _ := ctx.Value(ContextKeySubject).(string)
138+ subject, _ := ctx.Value(domain.ContextKeySubject).(string)
148139 return subject
149140 }
150141
151142 // GetScopes retrieves the token scopes from the context.
152143 func GetScopes(ctx context.Context) []string {
153- scopes, _ := ctx.Value(ContextKeyScopes).([]string)
144+ scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
154145 return scopes
155146 }
156147
@@ -16,16 +16,6 @@ import (
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17 )17 )
18 18
19-// contextKey is a custom type for context keys to avoid collisions.
20-type contextKey string
21-
22-const (
23- // ContextKeyScopes is the context key for storing token scopes.
24- ContextKeyScopes contextKey = "admin_scopes"
25- // ContextKeySubject is the context key for storing token subject.
26- ContextKeySubject contextKey = "admin_subject"
27-)
28-
29 // AuthMiddleware creates middleware that validates admin API authentication.19 // AuthMiddleware creates middleware that validates admin API authentication.
30 // Both globalLimiter and ipLimiter can be nil to disable rate limiting.20 // Both globalLimiter and ipLimiter can be nil to disable rate limiting.
31 // The trustedNets parameter is used for proper IP extraction behind reverse proxies.21 // The trustedNets parameter is used for proper IP extraction behind reverse proxies.
@@ -110,8 +100,9 @@ func AuthMiddleware(
110 }100 }
111 101
112 // Add claims to context for downstream handlers102 // Add claims to context for downstream handlers
113- ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)103+ ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
114- ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)104+ ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
105+ ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
115 106
116 next.ServeHTTP(w, r.WithContext(ctx))107 next.ServeHTTP(w, r.WithContext(ctx))
117 })108 })
@@ -125,7 +116,7 @@ func RequireScope(resource, action string) func(http.Handler) http.Handler {
125 ctx := r.Context()116 ctx := r.Context()
126 117
127 // Get scopes from context118 // Get scopes from context
128- scopes, ok := ctx.Value(ContextKeyScopes).([]string)119+ scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
129 if !ok {120 if !ok {
130 sendForbidden(w, "no scopes in context")121 sendForbidden(w, "no scopes in context")
131 return122 return
@@ -144,13 +135,13 @@ func RequireScope(resource, action string) func(http.Handler) http.Handler {
144 135
145 // GetSubject retrieves the authenticated subject from the context.136 // GetSubject retrieves the authenticated subject from the context.
146 func GetSubject(ctx context.Context) string {137 func GetSubject(ctx context.Context) string {
147- subject, _ := ctx.Value(ContextKeySubject).(string)138+ subject, _ := ctx.Value(domain.ContextKeySubject).(string)
148 return subject139 return subject
149 }140 }
150 141
151 // GetScopes retrieves the token scopes from the context.142 // GetScopes retrieves the token scopes from the context.
152 func GetScopes(ctx context.Context) []string {143 func GetScopes(ctx context.Context) []string {
153- scopes, _ := ctx.Value(ContextKeyScopes).([]string)144+ scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
154 return scopes145 return scopes
155 }146 }
156 147
M internal/adapters/in/http/admin/middleware_test.go +2 -2
@@ -340,7 +340,7 @@ func TestRequireScope(t *testing.T) {
340340
341341 req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
342342 // Add scopes to context
343- ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes)
343+ ctx := context.WithValue(req.Context(), domain.ContextKeyScopes, tt.scopes)
344344 req = req.WithContext(ctx)
345345
346346 rec := httptest.NewRecorder()
@@ -352,7 +352,7 @@ func TestRequireScope(t *testing.T) {
352352 }
353353
354354 func TestHasAccess(t *testing.T) {
355- ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
355+ ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
356356
357357 assert.True(t, HasAccess(ctx, "routes", "read"))
358358 assert.False(t, HasAccess(ctx, "routes", "write"))
@@ -340,7 +340,7 @@ func TestRequireScope(t *testing.T) {
340 340
341 req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)341 req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
342 // Add scopes to context342 // Add scopes to context
343- ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes)343+ ctx := context.WithValue(req.Context(), domain.ContextKeyScopes, tt.scopes)
344 req = req.WithContext(ctx)344 req = req.WithContext(ctx)
345 345
346 rec := httptest.NewRecorder()346 rec := httptest.NewRecorder()
@@ -352,7 +352,7 @@ func TestRequireScope(t *testing.T) {
352 }352 }
353 353
354 func TestHasAccess(t *testing.T) {354 func TestHasAccess(t *testing.T) {
355- ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})355+ ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
356 356
357 assert.True(t, HasAccess(ctx, "routes", "read"))357 assert.True(t, HasAccess(ctx, "routes", "read"))
358 assert.False(t, HasAccess(ctx, "routes", "write"))358 assert.False(t, HasAccess(ctx, "routes", "write"))
M internal/adapters/in/http/middleware/auth.go +3 -5
@@ -14,11 +14,9 @@ import (
1414 "github.com/bnema/gordon/internal/domain"
1515 )
1616
17-// contextKey is a type for context keys used by this package.
18-type contextKey string
19-
20-// TokenClaimsKey is the context key for storing token claims.
21-const TokenClaimsKey contextKey = "tokenClaims"
17+// TokenClaimsKey is context key for storing token claims.
18+// Using domain key for consistency across all auth flows.
19+const TokenClaimsKey = domain.TokenClaimsKey
2220
2321 // RegistryAuth middleware provides Docker Registry authentication.
2422 func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
@@ -14,11 +14,9 @@ import (
14 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15 )15 )
16 16
17-// contextKey is a type for context keys used by this package.17+// TokenClaimsKey is context key for storing token claims.
18-type contextKey string18+// Using domain key for consistency across all auth flows.
19-19+const TokenClaimsKey = domain.TokenClaimsKey
20-// TokenClaimsKey is the context key for storing token claims.
21-const TokenClaimsKey contextKey = "tokenClaims"
22 20
23 // RegistryAuth middleware provides Docker Registry authentication.21 // RegistryAuth middleware provides Docker Registry authentication.
24 func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {22 func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
M internal/adapters/in/http/middleware/auth_test.go +4 -0
@@ -501,6 +501,10 @@ func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
501501 return "", errors.New("not implemented")
502502 }
503503
504+func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
505+ return nil, errors.New("not implemented")
506+}
507+
504508 // Tests for checkScopeAccess function
505509
506510 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
@@ -501,6 +501,10 @@ func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
501 return "", errors.New("not implemented")501 return "", errors.New("not implemented")
502 }502 }
503 503
504+func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
505+ return nil, errors.New("not implemented")
506+}
507+
504 // Tests for checkScopeAccess function508 // Tests for checkScopeAccess function
505 509
506 func TestCheckScopeAccess_ActionMapping(t *testing.T) {510 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
M internal/boundaries/in/auth.go +4 -0
@@ -42,4 +42,8 @@ type AuthService interface {
4242
4343 // GeneratePasswordHash generates a bcrypt hash for a password.
4444 GeneratePasswordHash(password string) (string, error)
45+
46+ // GetAuthStatus returns authentication status from context.
47+ // Extracts and validates token claims that were set by auth middleware.
48+ GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
4549 }
@@ -42,4 +42,8 @@ type AuthService interface {
42 42
43 // GeneratePasswordHash generates a bcrypt hash for a password.43 // GeneratePasswordHash generates a bcrypt hash for a password.
44 GeneratePasswordHash(password string) (string, error)44 GeneratePasswordHash(password string) (string, error)
45+
46+ // GetAuthStatus returns authentication status from context.
47+ // Extracts and validates token claims that were set by auth middleware.
48+ GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
45 }49 }
M internal/boundaries/in/mocks/mock_auth_service.go +63 -1
@@ -6,9 +6,9 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109 "time"
1110
11+ "github.com/bnema/gordon/internal/domain"
1212 mock "github.com/stretchr/testify/mock"
1313 )
1414
@@ -255,6 +255,68 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
255255 return _c
256256 }
257257
258+// GetAuthStatus provides a mock function for the type MockAuthService
259+func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
260+ ret := _mock.Called(ctx)
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for GetAuthStatus")
264+ }
265+
266+ var r0 *domain.AuthStatus
267+ var r1 error
268+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*domain.AuthStatus, error)); ok {
269+ return returnFunc(ctx)
270+ }
271+ if returnFunc, ok := ret.Get(0).(func(context.Context) *domain.AuthStatus); ok {
272+ r0 = returnFunc(ctx)
273+ } else {
274+ if ret.Get(0) != nil {
275+ r0 = ret.Get(0).(*domain.AuthStatus)
276+ }
277+ }
278+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
279+ r1 = returnFunc(ctx)
280+ } else {
281+ r1 = ret.Error(1)
282+ }
283+ return r0, r1
284+}
285+
286+// MockAuthService_GetAuthStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthStatus'
287+type MockAuthService_GetAuthStatus_Call struct {
288+ *mock.Call
289+}
290+
291+// GetAuthStatus is a helper method to define mock.On call
292+// - ctx context.Context
293+func (_e *MockAuthService_Expecter) GetAuthStatus(ctx interface{}) *MockAuthService_GetAuthStatus_Call {
294+ return &MockAuthService_GetAuthStatus_Call{Call: _e.mock.On("GetAuthStatus", ctx)}
295+}
296+
297+func (_c *MockAuthService_GetAuthStatus_Call) Run(run func(ctx context.Context)) *MockAuthService_GetAuthStatus_Call {
298+ _c.Call.Run(func(args mock.Arguments) {
299+ var arg0 context.Context
300+ if args[0] != nil {
301+ arg0 = args[0].(context.Context)
302+ }
303+ run(
304+ arg0,
305+ )
306+ })
307+ return _c
308+}
309+
310+func (_c *MockAuthService_GetAuthStatus_Call) Return(authStatus *domain.AuthStatus, err error) *MockAuthService_GetAuthStatus_Call {
311+ _c.Call.Return(authStatus, err)
312+ return _c
313+}
314+
315+func (_c *MockAuthService_GetAuthStatus_Call) RunAndReturn(run func(ctx context.Context) (*domain.AuthStatus, error)) *MockAuthService_GetAuthStatus_Call {
316+ _c.Call.Return(run)
317+ return _c
318+}
319+
258320 // GetAuthType provides a mock function for the type MockAuthService
259321 func (_mock *MockAuthService) GetAuthType() domain.AuthType {
260322 ret := _mock.Called()
@@ -6,9 +6,9 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 "time"9 "time"
11 10
11+ "github.com/bnema/gordon/internal/domain"
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
14 14
@@ -255,6 +255,68 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
255 return _c255 return _c
256 }256 }
257 257
258+// GetAuthStatus provides a mock function for the type MockAuthService
259+func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
260+ ret := _mock.Called(ctx)
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for GetAuthStatus")
264+ }
265+
266+ var r0 *domain.AuthStatus
267+ var r1 error
268+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*domain.AuthStatus, error)); ok {
269+ return returnFunc(ctx)
270+ }
271+ if returnFunc, ok := ret.Get(0).(func(context.Context) *domain.AuthStatus); ok {
272+ r0 = returnFunc(ctx)
273+ } else {
274+ if ret.Get(0) != nil {
275+ r0 = ret.Get(0).(*domain.AuthStatus)
276+ }
277+ }
278+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
279+ r1 = returnFunc(ctx)
280+ } else {
281+ r1 = ret.Error(1)
282+ }
283+ return r0, r1
284+}
285+
286+// MockAuthService_GetAuthStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthStatus'
287+type MockAuthService_GetAuthStatus_Call struct {
288+ *mock.Call
289+}
290+
291+// GetAuthStatus is a helper method to define mock.On call
292+// - ctx context.Context
293+func (_e *MockAuthService_Expecter) GetAuthStatus(ctx interface{}) *MockAuthService_GetAuthStatus_Call {
294+ return &MockAuthService_GetAuthStatus_Call{Call: _e.mock.On("GetAuthStatus", ctx)}
295+}
296+
297+func (_c *MockAuthService_GetAuthStatus_Call) Run(run func(ctx context.Context)) *MockAuthService_GetAuthStatus_Call {
298+ _c.Call.Run(func(args mock.Arguments) {
299+ var arg0 context.Context
300+ if args[0] != nil {
301+ arg0 = args[0].(context.Context)
302+ }
303+ run(
304+ arg0,
305+ )
306+ })
307+ return _c
308+}
309+
310+func (_c *MockAuthService_GetAuthStatus_Call) Return(authStatus *domain.AuthStatus, err error) *MockAuthService_GetAuthStatus_Call {
311+ _c.Call.Return(authStatus, err)
312+ return _c
313+}
314+
315+func (_c *MockAuthService_GetAuthStatus_Call) RunAndReturn(run func(ctx context.Context) (*domain.AuthStatus, error)) *MockAuthService_GetAuthStatus_Call {
316+ _c.Call.Return(run)
317+ return _c
318+}
319+
258 // GetAuthType provides a mock function for the type MockAuthService320 // GetAuthType provides a mock function for the type MockAuthService
259 func (_mock *MockAuthService) GetAuthType() domain.AuthType {321 func (_mock *MockAuthService) GetAuthType() domain.AuthType {
260 ret := _mock.Called()322 ret := _mock.Called()
M internal/boundaries/in/mocks/mock_config_service.go +1 -1
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109
10+ "github.com/bnema/gordon/internal/domain"
1111 mock "github.com/stretchr/testify/mock"
1212 )
1313
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 9
10+ "github.com/bnema/gordon/internal/domain"
11 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
12 )12 )
13 13
M internal/boundaries/in/mocks/mock_container_service.go +1 -1
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109
10+ "github.com/bnema/gordon/internal/domain"
1111 mock "github.com/stretchr/testify/mock"
1212 )
1313
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 9
10+ "github.com/bnema/gordon/internal/domain"
11 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
12 )12 )
13 13
M internal/boundaries/in/mocks/mock_health_service.go +1 -1
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109
10+ "github.com/bnema/gordon/internal/domain"
1111 mock "github.com/stretchr/testify/mock"
1212 )
1313
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 9
10+ "github.com/bnema/gordon/internal/domain"
11 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
12 )12 )
13 13
M internal/boundaries/in/mocks/mock_proxy_service.go +1 -1
@@ -6,9 +6,9 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109 "net/http"
1110
11+ "github.com/bnema/gordon/internal/domain"
1212 mock "github.com/stretchr/testify/mock"
1313 )
1414
@@ -6,9 +6,9 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 "net/http"9 "net/http"
11 10
11+ "github.com/bnema/gordon/internal/domain"
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
14 14
M internal/boundaries/in/mocks/mock_registry_service.go +1 -1
@@ -6,9 +6,9 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109 "io"
1110
11+ "github.com/bnema/gordon/internal/domain"
1212 mock "github.com/stretchr/testify/mock"
1313 )
1414
@@ -6,9 +6,9 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 "io"9 "io"
11 10
11+ "github.com/bnema/gordon/internal/domain"
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
14 14
M internal/boundaries/in/mocks/mock_secret_service.go +1 -1
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/boundaries/out"
109
10+ "github.com/bnema/gordon/internal/boundaries/out"
1111 mock "github.com/stretchr/testify/mock"
1212 )
1313
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/boundaries/out"
10 9
10+ "github.com/bnema/gordon/internal/boundaries/out"
11 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
12 )12 )
13 13
M internal/boundaries/out/mocks/mock_container_runtime.go +1 -1
@@ -6,9 +6,9 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109 "io"
1110
11+ "github.com/bnema/gordon/internal/domain"
1212 mock "github.com/stretchr/testify/mock"
1313 )
1414
@@ -6,9 +6,9 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 "io"9 "io"
11 10
11+ "github.com/bnema/gordon/internal/domain"
12 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
13 )13 )
14 14
M internal/boundaries/out/mocks/mock_domain_secret_store.go +0 -1
@@ -6,7 +6,6 @@ package mocks
66
77 import (
88 "github.com/bnema/gordon/internal/boundaries/out"
9-
109 mock "github.com/stretchr/testify/mock"
1110 )
1211
@@ -6,7 +6,6 @@ package mocks
6 6
7 import (7 import (
8 "github.com/bnema/gordon/internal/boundaries/out"8 "github.com/bnema/gordon/internal/boundaries/out"
9-
10 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
11 )10 )
12 11
M internal/boundaries/out/mocks/mock_event_bus.go +0 -1
@@ -7,7 +7,6 @@ package mocks
77 import (
88 "github.com/bnema/gordon/internal/boundaries/out"
99 "github.com/bnema/gordon/internal/domain"
10-
1110 mock "github.com/stretchr/testify/mock"
1211 )
1312
@@ -7,7 +7,6 @@ package mocks
7 import (7 import (
8 "github.com/bnema/gordon/internal/boundaries/out"8 "github.com/bnema/gordon/internal/boundaries/out"
9 "github.com/bnema/gordon/internal/domain"9 "github.com/bnema/gordon/internal/domain"
10-
11 mock "github.com/stretchr/testify/mock"10 mock "github.com/stretchr/testify/mock"
12 )11 )
13 12
M internal/boundaries/out/mocks/mock_event_handler.go +0 -1
@@ -6,7 +6,6 @@ package mocks
66
77 import (
88 "github.com/bnema/gordon/internal/domain"
9-
109 mock "github.com/stretchr/testify/mock"
1110 )
1211
@@ -6,7 +6,6 @@ package mocks
6 6
7 import (7 import (
8 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
9-
10 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
11 )10 )
12 11
M internal/boundaries/out/mocks/mock_event_publisher.go +0 -1
@@ -6,7 +6,6 @@ package mocks
66
77 import (
88 "github.com/bnema/gordon/internal/domain"
9-
109 mock "github.com/stretchr/testify/mock"
1110 )
1211
@@ -6,7 +6,6 @@ package mocks
6 6
7 import (7 import (
8 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
9-
10 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
11 )10 )
12 11
M internal/boundaries/out/mocks/mock_event_subscriber.go +0 -1
@@ -6,7 +6,6 @@ package mocks
66
77 import (
88 "github.com/bnema/gordon/internal/boundaries/out"
9-
109 mock "github.com/stretchr/testify/mock"
1110 )
1211
@@ -6,7 +6,6 @@ package mocks
6 6
7 import (7 import (
8 "github.com/bnema/gordon/internal/boundaries/out"8 "github.com/bnema/gordon/internal/boundaries/out"
9-
10 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
11 )10 )
12 11
M internal/boundaries/out/mocks/mock_token_store.go +1 -1
@@ -6,8 +6,8 @@ package mocks
66
77 import (
88 "context"
9- "github.com/bnema/gordon/internal/domain"
109
10+ "github.com/bnema/gordon/internal/domain"
1111 mock "github.com/stretchr/testify/mock"
1212 )
1313
@@ -6,8 +6,8 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9- "github.com/bnema/gordon/internal/domain"
10 9
10+ "github.com/bnema/gordon/internal/domain"
11 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
12 )12 )
13 13
M internal/domain/auth.go +33 -0
@@ -2,6 +2,7 @@
22 package domain
33
44 import (
5+ "context"
56 "fmt"
67 "strings"
78 "time"
@@ -264,3 +265,35 @@ func AdminScopeConfig(actions ...string) string {
264265 func AdminScopeStatus(actions ...string) string {
265266 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
266267 }
268+
269+// AuthStatus represents status of an authentication session.
270+type AuthStatus struct {
271+ Valid bool
272+ Subject string
273+ Scopes []string
274+ ExpiresAt int64 // Unix timestamp, 0 if never expires
275+ IssuedAt int64 // Unix timestamp
276+}
277+
278+// Context keys for auth-related domain concerns.
279+type authContextKey string
280+
281+const (
282+ // TokenClaimsKey is the context key for storing TokenClaims.
283+ // Allows use cases and adapters to access validated token claims from context.
284+ TokenClaimsKey authContextKey = "token_claims"
285+ // ContextKeyScopes is the context key for storing token scopes.
286+ ContextKeyScopes authContextKey = "token_scopes"
287+ // ContextKeySubject is the context key for storing token subject.
288+ ContextKeySubject authContextKey = "token_subject"
289+)
290+
291+// GetTokenClaims extracts TokenClaims from context.
292+// Returns nil if claims are not present or type assertion fails.
293+func GetTokenClaims(ctx context.Context) *TokenClaims {
294+ claims, ok := ctx.Value(TokenClaimsKey).(*TokenClaims)
295+ if !ok {
296+ return nil
297+ }
298+ return claims
299+}
@@ -2,6 +2,7 @@
2 package domain2 package domain
3 3
4 import (4 import (
5+ "context"
5 "fmt"6 "fmt"
6 "strings"7 "strings"
7 "time"8 "time"
@@ -264,3 +265,35 @@ func AdminScopeConfig(actions ...string) string {
264 func AdminScopeStatus(actions ...string) string {265 func AdminScopeStatus(actions ...string) string {
265 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))266 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
266 }267 }
268+
269+// AuthStatus represents status of an authentication session.
270+type AuthStatus struct {
271+ Valid bool
272+ Subject string
273+ Scopes []string
274+ ExpiresAt int64 // Unix timestamp, 0 if never expires
275+ IssuedAt int64 // Unix timestamp
276+}
277+
278+// Context keys for auth-related domain concerns.
279+type authContextKey string
280+
281+const (
282+ // TokenClaimsKey is the context key for storing TokenClaims.
283+ // Allows use cases and adapters to access validated token claims from context.
284+ TokenClaimsKey authContextKey = "token_claims"
285+ // ContextKeyScopes is the context key for storing token scopes.
286+ ContextKeyScopes authContextKey = "token_scopes"
287+ // ContextKeySubject is the context key for storing token subject.
288+ ContextKeySubject authContextKey = "token_subject"
289+)
290+
291+// GetTokenClaims extracts TokenClaims from context.
292+// Returns nil if claims are not present or type assertion fails.
293+func GetTokenClaims(ctx context.Context) *TokenClaims {
294+ claims, ok := ctx.Value(TokenClaimsKey).(*TokenClaims)
295+ if !ok {
296+ return nil
297+ }
298+ return claims
299+}
M internal/usecase/auth/service.go +28 -0
@@ -449,3 +449,31 @@ func getStringClaim(claims jwt.MapClaims, key string) string {
449449 }
450450 return ""
451451 }
452+
453+// GetAuthStatus returns authentication status from context.
454+// Claims are already validated by AdminAuth middleware and stored in context.
455+func (s *Service) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
456+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
457+ zerowrap.FieldLayer: "usecase",
458+ zerowrap.FieldUseCase: "GetAuthStatus",
459+ })
460+
461+ // If auth is disabled, return valid status with no claims
462+ if !s.IsEnabled() {
463+ return &domain.AuthStatus{Valid: true}, nil
464+ }
465+
466+ // Extract claims from context (set by AdminAuth middleware)
467+ claims := domain.GetTokenClaims(ctx)
468+ if claims == nil {
469+ return &domain.AuthStatus{Valid: false}, nil
470+ }
471+
472+ return &domain.AuthStatus{
473+ Valid: true,
474+ Subject: claims.Subject,
475+ Scopes: claims.Scopes,
476+ ExpiresAt: claims.ExpiresAt,
477+ IssuedAt: claims.IssuedAt,
478+ }, nil
479+}
@@ -449,3 +449,31 @@ func getStringClaim(claims jwt.MapClaims, key string) string {
449 }449 }
450 return ""450 return ""
451 }451 }
452+
453+// GetAuthStatus returns authentication status from context.
454+// Claims are already validated by AdminAuth middleware and stored in context.
455+func (s *Service) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
456+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
457+ zerowrap.FieldLayer: "usecase",
458+ zerowrap.FieldUseCase: "GetAuthStatus",
459+ })
460+
461+ // If auth is disabled, return valid status with no claims
462+ if !s.IsEnabled() {
463+ return &domain.AuthStatus{Valid: true}, nil
464+ }
465+
466+ // Extract claims from context (set by AdminAuth middleware)
467+ claims := domain.GetTokenClaims(ctx)
468+ if claims == nil {
469+ return &domain.AuthStatus{Valid: false}, nil
470+ }
471+
472+ return &domain.AuthStatus{
473+ Valid: true,
474+ Subject: claims.Subject,
475+ Scopes: claims.Scopes,
476+ ExpiresAt: claims.ExpiresAt,
477+ IssuedAt: claims.IssuedAt,
478+ }, nil
479+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close