@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.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- 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
|
+
}
|