@nakedev/go-scaffold 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,389 @@
1
+ // Package google adapts Google's OAuth authorization-code flow to auth's
2
+ // provider-neutral LoginProvider port.
3
+ package google
4
+
5
+ import (
6
+ "context"
7
+ "crypto"
8
+ "crypto/rsa"
9
+ "crypto/subtle"
10
+ "encoding/json"
11
+ "encoding/base64"
12
+ "errors"
13
+ "fmt"
14
+ "math/big"
15
+ "net"
16
+ "net/http"
17
+ "strings"
18
+ "sync"
19
+ "time"
20
+
21
+ "{{goModule}}/internal/app/user/application"
22
+
23
+ "github.com/golang-jwt/jwt/v5"
24
+ "golang.org/x/oauth2"
25
+ oauthgoogle "golang.org/x/oauth2/google"
26
+ )
27
+
28
+ const (
29
+ defaultIssuer = "https://accounts.google.com"
30
+ defaultJWKSURL = "https://www.googleapis.com/oauth2/v3/certs"
31
+ defaultUserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
32
+ jwksCacheTTL = time.Hour
33
+ )
34
+
35
+ type Config struct {
36
+ ClientID string
37
+ ClientSecret string
38
+ RedirectURL string
39
+ Endpoint oauth2.Endpoint
40
+ UserInfoURL string
41
+ Issuer string
42
+ JWKSURL string
43
+ HTTPClient *http.Client
44
+ }
45
+
46
+ type Provider struct {
47
+ oauth *oauth2.Config
48
+ userInfoURL string
49
+ issuer string
50
+ jwksURL string
51
+ httpClient *http.Client
52
+ keysMu sync.RWMutex
53
+ keysFetched time.Time
54
+ keys map[string]crypto.PublicKey
55
+ }
56
+
57
+ func New(cfg Config) *Provider {
58
+ endpoint := cfg.Endpoint
59
+ if endpoint.AuthURL == "" || endpoint.TokenURL == "" {
60
+ endpoint = oauthgoogle.Endpoint
61
+ }
62
+ userInfoURL := cfg.UserInfoURL
63
+ if userInfoURL == "" {
64
+ userInfoURL = defaultUserInfoURL
65
+ }
66
+ issuer := cfg.Issuer
67
+ if issuer == "" {
68
+ issuer = defaultIssuer
69
+ }
70
+ jwksURL := cfg.JWKSURL
71
+ if jwksURL == "" {
72
+ jwksURL = defaultJWKSURL
73
+ }
74
+ httpClient := cfg.HTTPClient
75
+ if httpClient == nil {
76
+ httpClient = &http.Client{Timeout: 10 * time.Second}
77
+ }
78
+ return &Provider{
79
+ oauth: &oauth2.Config{
80
+ ClientID: cfg.ClientID,
81
+ ClientSecret: cfg.ClientSecret,
82
+ RedirectURL: cfg.RedirectURL,
83
+ Scopes: []string{"openid", "email", "profile"},
84
+ Endpoint: endpoint,
85
+ },
86
+ userInfoURL: userInfoURL,
87
+ issuer: issuer,
88
+ jwksURL: jwksURL,
89
+ httpClient: httpClient,
90
+ keys: make(map[string]crypto.PublicKey),
91
+ }
92
+ }
93
+
94
+ func (p *Provider) Name() string { return "google" }
95
+
96
+ func (p *Provider) Begin(_ context.Context, in application.LoginStartInput) (application.Authorization, error) {
97
+ if p == nil || p.oauth == nil || p.oauth.ClientID == "" || p.oauth.ClientSecret == "" || p.oauth.RedirectURL == "" {
98
+ return application.Authorization{}, application.NewProviderUnavailable(fmt.Errorf("google oauth is not configured"))
99
+ }
100
+ if !validOAuthValue(in.State) || !validPKCEValue(in.CodeChallenge) || in.CodeChallengeMethod != "S256" || !validOAuthValue(in.Nonce) {
101
+ return application.Authorization{}, application.NewProviderFailure(fmt.Errorf("invalid oauth state or PKCE parameters"))
102
+ }
103
+ return application.Authorization{
104
+ URL: p.oauth.AuthCodeURL(
105
+ in.State,
106
+ oauth2.SetAuthURLParam("code_challenge", in.CodeChallenge),
107
+ oauth2.SetAuthURLParam("code_challenge_method", in.CodeChallengeMethod),
108
+ oauth2.SetAuthURLParam("nonce", in.Nonce),
109
+ ),
110
+ }, nil
111
+ }
112
+
113
+ type userInfo struct {
114
+ Sub string `json:"sub"`
115
+ Email string `json:"email"`
116
+ EmailVerified bool `json:"email_verified"`
117
+ Name string `json:"name"`
118
+ Picture string `json:"picture"`
119
+ }
120
+
121
+ type idTokenClaims struct {
122
+ Nonce string `json:"nonce"`
123
+ AuthorizedParty string `json:"azp"`
124
+ Email string `json:"email"`
125
+ EmailVerified bool `json:"email_verified"`
126
+ Name string `json:"name"`
127
+ Picture string `json:"picture"`
128
+ jwt.RegisteredClaims
129
+ }
130
+
131
+ type jsonWebKeySet struct {
132
+ Keys []jsonWebKey `json:"keys"`
133
+ }
134
+
135
+ type jsonWebKey struct {
136
+ Kid string `json:"kid"`
137
+ Kty string `json:"kty"`
138
+ Alg string `json:"alg"`
139
+ Use string `json:"use"`
140
+ N string `json:"n"`
141
+ E string `json:"e"`
142
+ }
143
+
144
+ func (p *Provider) Complete(ctx context.Context, in application.LoginCompleteInput) (application.ExternalIdentity, error) {
145
+ if p == nil || p.oauth == nil {
146
+ return application.ExternalIdentity{}, application.NewProviderUnavailable(fmt.Errorf("google oauth is not configured"))
147
+ }
148
+ if !validOAuthValue(in.Code) || !validPKCEValue(in.CodeVerifier) || !validOAuthValue(in.Nonce) {
149
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("authorization code and PKCE verifier are required"))
150
+ }
151
+
152
+ ctx = context.WithValue(ctx, oauth2.HTTPClient, p.httpClient)
153
+ tok, err := p.oauth.Exchange(ctx, in.Code, oauth2.SetAuthURLParam("code_verifier", in.CodeVerifier))
154
+ if err != nil {
155
+ var retrieveErr *oauth2.RetrieveError
156
+ if errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "access_denied" {
157
+ return application.ExternalIdentity{}, application.NewOAuthError(application.OAuthDenied, fmt.Errorf("google authorization was denied"))
158
+ }
159
+ if isProviderUnavailable(err) {
160
+ return application.ExternalIdentity{}, application.NewProviderUnavailable(fmt.Errorf("exchange google authorization code: %w", err))
161
+ }
162
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("exchange google authorization code: %w", err))
163
+ }
164
+ if tok.AccessToken == "" {
165
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("google token response is missing an access token"))
166
+ }
167
+ rawIDToken, ok := tok.Extra("id_token").(string)
168
+ if !ok || strings.TrimSpace(rawIDToken) == "" {
169
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("google token response is missing an id token"))
170
+ }
171
+ claims, err := p.validateIDToken(ctx, rawIDToken, in.Nonce)
172
+ if err != nil {
173
+ if application.IsProviderUnavailable(err) {
174
+ return application.ExternalIdentity{}, err
175
+ }
176
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("validate google id token: %w", err))
177
+ }
178
+
179
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.userInfoURL, nil)
180
+ if err != nil {
181
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("build google userinfo request: %w", err))
182
+ }
183
+ req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
184
+ resp, err := p.httpClient.Do(req)
185
+ if err != nil {
186
+ return application.ExternalIdentity{}, application.NewProviderUnavailable(fmt.Errorf("request google userinfo: %w", err))
187
+ }
188
+ defer func() { _ = resp.Body.Close() }()
189
+ if resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests {
190
+ return application.ExternalIdentity{}, application.NewProviderUnavailable(fmt.Errorf("google userinfo returned %s", resp.Status))
191
+ }
192
+ if resp.StatusCode != http.StatusOK {
193
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("google userinfo returned %s", resp.Status))
194
+ }
195
+
196
+ var info userInfo
197
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
198
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("decode google userinfo: %w", err))
199
+ }
200
+ if strings.TrimSpace(info.Sub) == "" || info.Sub != claims.Subject {
201
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("google userinfo subject does not match id token"))
202
+ }
203
+ email := strings.TrimSpace(info.Email)
204
+ if email == "" {
205
+ email = strings.TrimSpace(claims.Email)
206
+ }
207
+ if email == "" {
208
+ return application.ExternalIdentity{}, application.NewProviderFailure(fmt.Errorf("google userinfo is missing subject or email"))
209
+ }
210
+ name := info.Name
211
+ if name == "" {
212
+ name = claims.Name
213
+ }
214
+ picture := info.Picture
215
+ if picture == "" {
216
+ picture = claims.Picture
217
+ }
218
+ return application.ExternalIdentity{
219
+ Provider: p.Name(),
220
+ Subject: claims.Subject,
221
+ Email: email,
222
+ EmailVerified: info.EmailVerified || claims.EmailVerified,
223
+ Name: name,
224
+ AvatarURL: picture,
225
+ }, nil
226
+ }
227
+
228
+ // OAuth state and authorization codes are opaque RFC 6749 VSCHAR values. The
229
+ // provider validates their transport-safe shape without normalizing them.
230
+ func validOAuthValue(value string) bool {
231
+ if value == "" {
232
+ return false
233
+ }
234
+ for i := 0; i < len(value); i++ {
235
+ if value[i] < 0x20 || value[i] > 0x7e {
236
+ return false
237
+ }
238
+ }
239
+ return true
240
+ }
241
+
242
+ // RFC 7636 defines code_verifier/code_challenge as 43–128 unreserved ASCII
243
+ // characters. S256 is the only method accepted by this scaffold.
244
+ func validPKCEValue(value string) bool {
245
+ if len(value) < 43 || len(value) > 128 {
246
+ return false
247
+ }
248
+ for i := 0; i < len(value); i++ {
249
+ c := value[i]
250
+ switch {
251
+ case c >= 'A' && c <= 'Z':
252
+ case c >= 'a' && c <= 'z':
253
+ case c >= '0' && c <= '9':
254
+ case c == '-' || c == '.' || c == '_' || c == '~':
255
+ default:
256
+ return false
257
+ }
258
+ }
259
+ return true
260
+ }
261
+
262
+ func (p *Provider) validateIDToken(ctx context.Context, raw, expectedNonce string) (idTokenClaims, error) {
263
+ if strings.TrimSpace(expectedNonce) == "" {
264
+ return idTokenClaims{}, fmt.Errorf("oidc nonce is missing")
265
+ }
266
+ var claims idTokenClaims
267
+ token, err := jwt.ParseWithClaims(raw, &claims, func(token *jwt.Token) (any, error) {
268
+ if token.Method == nil || token.Method.Alg() != jwt.SigningMethodRS256.Name {
269
+ return nil, fmt.Errorf("unsupported google id token signing method")
270
+ }
271
+ kid, ok := token.Header["kid"].(string)
272
+ if !ok || strings.TrimSpace(kid) == "" {
273
+ return nil, fmt.Errorf("google id token is missing a key id")
274
+ }
275
+ return p.publicKey(ctx, kid)
276
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Name}), jwt.WithIssuer(p.issuer), jwt.WithAudience(p.oauth.ClientID), jwt.WithLeeway(time.Minute))
277
+ if err != nil {
278
+ return idTokenClaims{}, err
279
+ }
280
+ if token == nil || !token.Valid {
281
+ return idTokenClaims{}, fmt.Errorf("google id token is invalid")
282
+ }
283
+ if claims.ExpiresAt == nil || claims.IssuedAt == nil || strings.TrimSpace(claims.Subject) == "" {
284
+ return idTokenClaims{}, fmt.Errorf("google id token is missing required claims")
285
+ }
286
+ if claims.AuthorizedParty != "" && claims.AuthorizedParty != p.oauth.ClientID {
287
+ return idTokenClaims{}, fmt.Errorf("google id token authorized party is invalid")
288
+ }
289
+ if len(claims.Audience) > 1 && claims.AuthorizedParty != p.oauth.ClientID {
290
+ return idTokenClaims{}, fmt.Errorf("google id token authorized party is required")
291
+ }
292
+ if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(expectedNonce)) != 1 {
293
+ return idTokenClaims{}, fmt.Errorf("google id token nonce does not match")
294
+ }
295
+ return claims, nil
296
+ }
297
+
298
+ func (p *Provider) publicKey(ctx context.Context, kid string) (crypto.PublicKey, error) {
299
+ now := time.Now()
300
+ p.keysMu.RLock()
301
+ key, found := p.keys[kid]
302
+ fresh := now.Sub(p.keysFetched) < jwksCacheTTL
303
+ p.keysMu.RUnlock()
304
+ if found && fresh {
305
+ return key, nil
306
+ }
307
+
308
+ keys, err := p.fetchKeys(ctx)
309
+ if err != nil {
310
+ return nil, err
311
+ }
312
+ p.keysMu.Lock()
313
+ p.keys = keys
314
+ p.keysFetched = now
315
+ key, found = keys[kid]
316
+ p.keysMu.Unlock()
317
+ if !found {
318
+ return nil, fmt.Errorf("google jwks does not contain requested key")
319
+ }
320
+ return key, nil
321
+ }
322
+
323
+ func (p *Provider) fetchKeys(ctx context.Context) (map[string]crypto.PublicKey, error) {
324
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.jwksURL, nil)
325
+ if err != nil {
326
+ return nil, application.NewProviderFailure(fmt.Errorf("build google jwks request: %w", err))
327
+ }
328
+ resp, err := p.httpClient.Do(req)
329
+ if err != nil {
330
+ return nil, application.NewProviderUnavailable(fmt.Errorf("request google jwks: %w", err))
331
+ }
332
+ defer func() { _ = resp.Body.Close() }()
333
+ if resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests {
334
+ return nil, application.NewProviderUnavailable(fmt.Errorf("google jwks returned %s", resp.Status))
335
+ }
336
+ if resp.StatusCode != http.StatusOK {
337
+ return nil, application.NewProviderFailure(fmt.Errorf("google jwks returned %s", resp.Status))
338
+ }
339
+ var document jsonWebKeySet
340
+ if err := json.NewDecoder(resp.Body).Decode(&document); err != nil {
341
+ return nil, application.NewProviderFailure(fmt.Errorf("decode google jwks: %w", err))
342
+ }
343
+ keys := make(map[string]crypto.PublicKey, len(document.Keys))
344
+ for _, jwk := range document.Keys {
345
+ key, err := rsaPublicKey(jwk)
346
+ if err != nil {
347
+ return nil, application.NewProviderFailure(fmt.Errorf("decode google jwks key: %w", err))
348
+ }
349
+ keys[jwk.Kid] = key
350
+ }
351
+ if len(keys) == 0 {
352
+ return nil, application.NewProviderFailure(fmt.Errorf("google jwks contains no signing keys"))
353
+ }
354
+ return keys, nil
355
+ }
356
+
357
+ func rsaPublicKey(jwk jsonWebKey) (*rsa.PublicKey, error) {
358
+ if jwk.Kty != "RSA" || jwk.Alg != jwt.SigningMethodRS256.Name || strings.TrimSpace(jwk.Kid) == "" || (jwk.Use != "" && jwk.Use != "sig") {
359
+ return nil, fmt.Errorf("unsupported jwk")
360
+ }
361
+ modulus, err := base64.RawURLEncoding.DecodeString(jwk.N)
362
+ if err != nil || len(modulus) == 0 {
363
+ return nil, fmt.Errorf("invalid rsa modulus")
364
+ }
365
+ exponent, err := base64.RawURLEncoding.DecodeString(jwk.E)
366
+ if err != nil || len(exponent) == 0 || len(exponent) > 4 {
367
+ return nil, fmt.Errorf("invalid rsa exponent")
368
+ }
369
+ e := 0
370
+ for _, b := range exponent {
371
+ e = e<<8 | int(b)
372
+ }
373
+ if e < 2 {
374
+ return nil, fmt.Errorf("invalid rsa exponent")
375
+ }
376
+ return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: e}, nil
377
+ }
378
+
379
+ func isProviderUnavailable(err error) bool {
380
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
381
+ return true
382
+ }
383
+ var retrieveErr *oauth2.RetrieveError
384
+ if errors.As(err, &retrieveErr) {
385
+ return retrieveErr.Response == nil || retrieveErr.Response.StatusCode >= http.StatusInternalServerError || retrieveErr.Response.StatusCode == http.StatusRequestTimeout || retrieveErr.Response.StatusCode == http.StatusTooManyRequests
386
+ }
387
+ var networkErr net.Error
388
+ return errors.As(err, &networkErr)
389
+ }
@@ -0,0 +1,312 @@
1
+ package google
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "crypto/rsa"
7
+ "encoding/base64"
8
+ "encoding/json"
9
+ "errors"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "net/url"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "{{goModule}}/internal/app/user/application"
18
+
19
+ "github.com/golang-jwt/jwt/v5"
20
+ "golang.org/x/oauth2"
21
+ )
22
+
23
+ const (
24
+ oidcFixtureNonce = "server-nonce"
25
+ oidcFixtureVerifier = "verifier-that-the-service-bound-to-state-0123456789"
26
+ )
27
+
28
+ func newOIDCProviderFixture(t *testing.T, userInfoStatus int) (*Provider, *httptest.Server) {
29
+ t.Helper()
30
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
31
+ if err != nil {
32
+ t.Fatalf("generate OIDC test key: %v", err)
33
+ }
34
+ const kid = "test-key"
35
+ var server *httptest.Server
36
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37
+ switch r.URL.Path {
38
+ case "/token":
39
+ idToken, err := signOIDCTestToken(privateKey, server.URL, "client-id", oidcFixtureNonce, kid)
40
+ if err != nil {
41
+ http.Error(w, err.Error(), http.StatusInternalServerError)
42
+ return
43
+ }
44
+ w.Header().Set("Content-Type", "application/json")
45
+ _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access-token", "id_token": idToken, "token_type": "Bearer", "expires_in": 3600})
46
+ case "/jwks":
47
+ w.Header().Set("Content-Type", "application/json")
48
+ key := jsonWebKey{
49
+ Kid: kid,
50
+ Kty: "RSA",
51
+ Alg: jwt.SigningMethodRS256.Name,
52
+ Use: "sig",
53
+ N: base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
54
+ E: base64.RawURLEncoding.EncodeToString(bigIntBytes(privateKey.E)),
55
+ }
56
+ _ = json.NewEncoder(w).Encode(jsonWebKeySet{Keys: []jsonWebKey{key}})
57
+ case "/userinfo":
58
+ if userInfoStatus != 0 {
59
+ http.Error(w, "userinfo unavailable", userInfoStatus)
60
+ return
61
+ }
62
+ w.Header().Set("Content-Type", "application/json")
63
+ _ = json.NewEncoder(w).Encode(map[string]any{"sub": "subject-1", "email": "User@Example.com", "email_verified": true, "name": "User", "picture": "https://img.example/user"})
64
+ default:
65
+ http.NotFound(w, r)
66
+ }
67
+ }))
68
+ provider := New(Config{
69
+ ClientID: "client-id",
70
+ ClientSecret: "client-secret",
71
+ RedirectURL: "https://app.example.test/oauth/callback/google",
72
+ Endpoint: oauth2.Endpoint{AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token"},
73
+ UserInfoURL: server.URL + "/userinfo",
74
+ Issuer: server.URL,
75
+ JWKSURL: server.URL + "/jwks",
76
+ HTTPClient: server.Client(),
77
+ })
78
+ return provider, server
79
+ }
80
+
81
+ func signOIDCTestToken(privateKey *rsa.PrivateKey, issuer, audience, nonce, kid string) (string, error) {
82
+ now := time.Now()
83
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, idTokenClaims{
84
+ Nonce: nonce,
85
+ AuthorizedParty: audience,
86
+ Email: "User@Example.com",
87
+ EmailVerified: true,
88
+ Name: "User",
89
+ RegisteredClaims: jwt.RegisteredClaims{
90
+ Issuer: issuer,
91
+ Subject: "subject-1",
92
+ Audience: jwt.ClaimStrings{audience},
93
+ IssuedAt: jwt.NewNumericDate(now),
94
+ ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
95
+ },
96
+ })
97
+ token.Header["kid"] = kid
98
+ return token.SignedString(privateKey)
99
+ }
100
+
101
+ func bigIntBytes(value int) []byte {
102
+ return []byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}
103
+ }
104
+
105
+ func TestProvider_Begin_UsesStateAndS256PKCE(t *testing.T) {
106
+ provider := New(Config{
107
+ ClientID: "client-id",
108
+ ClientSecret: "client-secret",
109
+ RedirectURL: "https://app.example.test/oauth/callback/google",
110
+ Endpoint: oauth2.Endpoint{
111
+ AuthURL: "https://provider.example.test/authorize",
112
+ TokenURL: "https://provider.example.test/token",
113
+ },
114
+ })
115
+
116
+ authorization, err := provider.Begin(context.Background(), application.LoginStartInput{
117
+ State: "signed-state",
118
+ CodeChallenge: strings.Repeat("A", 43),
119
+ CodeChallengeMethod: "S256",
120
+ Nonce: "signed-nonce",
121
+ })
122
+ if err != nil {
123
+ t.Fatalf("begin login: %v", err)
124
+ }
125
+ parsed, err := url.Parse(authorization.URL)
126
+ if err != nil {
127
+ t.Fatalf("parse authorization URL: %v", err)
128
+ }
129
+ if got := parsed.Query().Get("state"); got != "signed-state" {
130
+ t.Fatalf("state = %q, want signed-state", got)
131
+ }
132
+ if got := parsed.Query().Get("code_challenge"); got != strings.Repeat("A", 43) {
133
+ t.Fatalf("code_challenge = %q, want a valid 43-character S256 challenge", got)
134
+ }
135
+ if got := parsed.Query().Get("code_challenge_method"); got != "S256" {
136
+ t.Fatalf("code_challenge_method = %q, want S256", got)
137
+ }
138
+ if got := parsed.Query().Get("nonce"); got != "signed-nonce" {
139
+ t.Fatalf("nonce = %q, want signed-nonce", got)
140
+ }
141
+ if got := parsed.Query().Get("redirect_uri"); got != "https://app.example.test/oauth/callback/google" {
142
+ t.Fatalf("redirect_uri = %q, want the exact configured frontend callback URI", got)
143
+ }
144
+ }
145
+
146
+ func TestProvider_Begin_RejectsInvalidOAuthAndPKCEParameters(t *testing.T) {
147
+ provider := New(Config{
148
+ ClientID: "client-id",
149
+ ClientSecret: "client-secret",
150
+ RedirectURL: "https://app.example.test/oauth/callback/google",
151
+ Endpoint: oauth2.Endpoint{
152
+ AuthURL: "https://provider.example.test/authorize",
153
+ TokenURL: "https://provider.example.test/token",
154
+ },
155
+ })
156
+ validChallenge := strings.Repeat("A", 43)
157
+ tests := []struct {
158
+ name string
159
+ in application.LoginStartInput
160
+ }{
161
+ {name: "short challenge", in: application.LoginStartInput{State: "state", CodeChallenge: "short", CodeChallengeMethod: "S256", Nonce: "nonce"}},
162
+ {name: "invalid challenge character", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("!", 43), CodeChallengeMethod: "S256", Nonce: "nonce"}},
163
+ {name: "lowercase challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: validChallenge, CodeChallengeMethod: "s256", Nonce: "nonce"}},
164
+ {name: "missing nonce", in: application.LoginStartInput{State: "state", CodeChallenge: validChallenge, CodeChallengeMethod: "S256"}},
165
+ }
166
+ for _, tt := range tests {
167
+ t.Run(tt.name, func(t *testing.T) {
168
+ if _, err := provider.Begin(context.Background(), tt.in); err == nil || application.IsProviderUnavailable(err) {
169
+ t.Fatalf("expected a controlled provider failure, got %T: %v", err, err)
170
+ }
171
+ })
172
+ }
173
+ }
174
+
175
+ func TestProvider_Complete_RejectsInvalidOAuthAndPKCEParameters(t *testing.T) {
176
+ provider := New(Config{
177
+ ClientID: "client-id",
178
+ ClientSecret: "client-secret",
179
+ RedirectURL: "https://app.example.test/oauth/callback/google",
180
+ Endpoint: oauth2.Endpoint{
181
+ AuthURL: "http://127.0.0.1:1/authorize",
182
+ TokenURL: "http://127.0.0.1:1/token",
183
+ },
184
+ })
185
+ tests := []struct {
186
+ name string
187
+ in application.LoginCompleteInput
188
+ }{
189
+ {name: "missing code", in: application.LoginCompleteInput{CodeVerifier: oidcFixtureVerifier, Nonce: "nonce"}},
190
+ {name: "short verifier", in: application.LoginCompleteInput{Code: "code", CodeVerifier: "short", Nonce: "nonce"}},
191
+ {name: "invalid verifier character", in: application.LoginCompleteInput{Code: "code", CodeVerifier: strings.Repeat("!", 43), Nonce: "nonce"}},
192
+ {name: "missing nonce", in: application.LoginCompleteInput{Code: "code", CodeVerifier: oidcFixtureVerifier}},
193
+ }
194
+ for _, tt := range tests {
195
+ t.Run(tt.name, func(t *testing.T) {
196
+ if _, err := provider.Complete(context.Background(), tt.in); err == nil || application.IsProviderUnavailable(err) {
197
+ t.Fatalf("expected a controlled provider failure before upstream exchange, got %T: %v", err, err)
198
+ }
199
+ })
200
+ }
201
+ }
202
+
203
+ func TestProvider_Complete_ExchangesCodeAndNormalizesIdentity(t *testing.T) {
204
+ provider, server := newOIDCProviderFixture(t, 0)
205
+ defer server.Close()
206
+
207
+ identity, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: oidcFixtureNonce})
208
+ if err != nil {
209
+ t.Fatalf("complete login: %v", err)
210
+ }
211
+ if identity.Provider != "google" || identity.Subject != "subject-1" || identity.Email != "User@Example.com" || !identity.EmailVerified || identity.Name != "User" || identity.AvatarURL == "" {
212
+ t.Fatalf("unexpected normalized identity: %+v", identity)
213
+ }
214
+ }
215
+
216
+ func TestProvider_Complete_RejectsAnOIDCNonceMismatch(t *testing.T) {
217
+ provider, server := newOIDCProviderFixture(t, 0)
218
+ defer server.Close()
219
+ _, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: "wrong-nonce"})
220
+ if err == nil || application.IsProviderUnavailable(err) {
221
+ t.Fatalf("expected a controlled OIDC validation failure, got %T: %v", err, err)
222
+ }
223
+ }
224
+
225
+ func TestProvider_Complete_RejectsMissingIDToken(t *testing.T) {
226
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
227
+ if r.URL.Path != "/token" {
228
+ http.NotFound(w, r)
229
+ return
230
+ }
231
+ w.Header().Set("Content-Type", "application/json")
232
+ _, _ = w.Write([]byte(`{"access_token":"access-token","token_type":"Bearer"}`))
233
+ }))
234
+ defer server.Close()
235
+ provider := New(Config{
236
+ ClientID: "client-id",
237
+ ClientSecret: "client-secret",
238
+ RedirectURL: "https://app.example.test/oauth/callback/google",
239
+ Endpoint: oauth2.Endpoint{AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token"},
240
+ HTTPClient: server.Client(),
241
+ })
242
+ _, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: "nonce"})
243
+ if err == nil || application.IsProviderUnavailable(err) {
244
+ t.Fatalf("expected missing ID token to be a provider failure, got %T: %v", err, err)
245
+ }
246
+ }
247
+
248
+ func TestProvider_Complete_MapsAccessDeniedToControlledOAuthCode(t *testing.T) {
249
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
250
+ if r.URL.Path != "/token" {
251
+ http.NotFound(w, r)
252
+ return
253
+ }
254
+ w.Header().Set("Content-Type", "application/json")
255
+ w.WriteHeader(http.StatusBadRequest)
256
+ _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"provider detail must not be public"}`))
257
+ }))
258
+ defer server.Close()
259
+ provider := New(Config{
260
+ ClientID: "client-id",
261
+ ClientSecret: "client-secret",
262
+ RedirectURL: "https://app.example.test/oauth/callback/google",
263
+ Endpoint: oauth2.Endpoint{AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token"},
264
+ HTTPClient: server.Client(),
265
+ })
266
+ _, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: "nonce"})
267
+ var oauthErr *application.OAuthError
268
+ if !errors.As(err, &oauthErr) || oauthErr.Code != application.OAuthDenied || strings.Contains(err.Error(), "provider detail") {
269
+ t.Fatalf("expected controlled oauth_denied without provider detail, got %T: %v", err, err)
270
+ }
271
+ }
272
+
273
+ func TestProvider_Begin_UnconfiguredProviderIsUnavailable(t *testing.T) {
274
+ provider := New(Config{})
275
+ _, err := provider.Begin(context.Background(), application.LoginStartInput{State: "state", CodeChallenge: "challenge", CodeChallengeMethod: "S256"})
276
+ if !application.IsProviderUnavailable(err) {
277
+ t.Fatalf("expected provider unavailable error, got %T: %v", err, err)
278
+ }
279
+ }
280
+
281
+ func TestProvider_Complete_ClassifiesExchangeUpstreamFailureAsUnavailable(t *testing.T) {
282
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
283
+ if r.URL.Path != "/token" {
284
+ http.NotFound(w, r)
285
+ return
286
+ }
287
+ http.Error(w, "upstream unavailable", http.StatusBadGateway)
288
+ }))
289
+ defer server.Close()
290
+
291
+ provider := New(Config{
292
+ ClientID: "client-id",
293
+ ClientSecret: "client-secret",
294
+ RedirectURL: "https://app.example.test/oauth/callback/google",
295
+ Endpoint: oauth2.Endpoint{AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token"},
296
+ HTTPClient: server.Client(),
297
+ })
298
+ _, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: "nonce"})
299
+ if !application.IsProviderUnavailable(err) {
300
+ t.Fatalf("expected exchange upstream failure to be unavailable, got %T: %v", err, err)
301
+ }
302
+ }
303
+
304
+ func TestProvider_Complete_ClassifiesUserInfoUpstreamFailureAsUnavailable(t *testing.T) {
305
+ provider, server := newOIDCProviderFixture(t, http.StatusServiceUnavailable)
306
+ defer server.Close()
307
+
308
+ _, err := provider.Complete(context.Background(), application.LoginCompleteInput{Code: "authorization-code", CodeVerifier: oidcFixtureVerifier, Nonce: oidcFixtureNonce})
309
+ if !application.IsProviderUnavailable(err) {
310
+ t.Fatalf("expected userinfo upstream failure to be unavailable, got %T: %v", err, err)
311
+ }
312
+ }