已合并
WEB-02: implement Telegram OIDC BFF and YDB sessions #22
urandon创建于 8月5日
WEB-02: implement Telegram OIDC BFF and YDB sessions #22
已合并
共 38 个文件变更+3742-23
| @@ -1,5 +1,6 @@ | |||
| 1 | # Safe local defaults only. Never store tokens, API keys, or service-account JSON here. | 1 | # Safe local defaults only. Never store tokens, API keys, or service-account JSON here. |
| 2 | APP_ENV=local | 2 | APP_ENV=local |
| 3 | +SESSIONLESS_ENVIRONMENT=local | ||
| 3 | LOG_LEVEL=debug | 4 | LOG_LEVEL=debug |
| 4 | PORT=8080 | 5 | PORT=8080 |
| 5 | 6 | ||
| @@ -56,3 +57,20 @@ DETERMINISTIC_HARNESS_RETRYABLE_FAIL=false | |||
| 56 | TELEGRAM_BOT_TOKEN= | 57 | TELEGRAM_BOT_TOKEN= |
| 57 | TELEGRAM_WEBHOOK_SECRET= | 58 | TELEGRAM_WEBHOOK_SECRET= |
| 58 | TELEGRAM_IDENTITY_HMAC_KEY= | 59 | TELEGRAM_IDENTITY_HMAC_KEY= |
| 60 | + | ||
| 61 | +# Web BFF and local-only Telegram-shaped OIDC fixture. Values below are | ||
| 62 | +# coordinates, not production credentials. Keep real client secrets in an OS | ||
| 63 | +# secret store locally and in Lockbox for cloud deployments. | ||
| 64 | +WEB_BASE_URL=https://web.localhost | ||
| 65 | +WEB_PORT=8083 | ||
| 66 | +TELEGRAM_OIDC_ISSUER=https://oidc.localhost | ||
| 67 | +TELEGRAM_OIDC_AUTHORIZATION_ENDPOINT=http://127.0.0.1:8082/auth | ||
| 68 | +TELEGRAM_OIDC_TOKEN_ENDPOINT=http://127.0.0.1:8082/token | ||
| 69 | +TELEGRAM_OIDC_JWKS_URL=http://127.0.0.1:8082/.well-known/jwks.json | ||
| 70 | +TELEGRAM_OIDC_CLIENT_ID=100000 | ||
| 71 | +TELEGRAM_OIDC_CLIENT_SECRET= | ||
| 72 | +OIDC_FIXTURE_ISSUER=https://oidc.localhost | ||
| 73 | +OIDC_FIXTURE_CLIENT_ID=100000 | ||
| 74 | +OIDC_FIXTURE_CLIENT_SECRET= | ||
| 75 | +OIDC_FIXTURE_REDIRECT_URI=https://web.localhost/auth/telegram/callback | ||
| 76 | +OIDC_FIXTURE_SUBJECT=424242 | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "log/slog" | ||
| 6 | + "net/http" | ||
| 7 | + "os" | ||
| 8 | + "strconv" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "gitcode.com/urandon/sessionless/internal/oidcfixture" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +func main() { | ||
| 15 | + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) | ||
| 16 | + port := envOrDefault("PORT", "8082") | ||
| 17 | + issuer := envOrDefault("OIDC_FIXTURE_ISSUER", "http://127.0.0.1:"+port) | ||
| 18 | + server, err := oidcfixture.New(oidcfixture.Config{ | ||
| 19 | + Environment: os.Getenv("SESSIONLESS_ENVIRONMENT"), Issuer: issuer, | ||
| 20 | + ClientID: envOrDefault("OIDC_FIXTURE_CLIENT_ID", "100000"), | ||
| 21 | + ClientSecret: envOrDefault("OIDC_FIXTURE_CLIENT_SECRET", "local-fixture-secret"), | ||
| 22 | + RedirectURI: envOrDefault("OIDC_FIXTURE_REDIRECT_URI", "https://web.localhost/auth/telegram/callback"), | ||
| 23 | + Subject: envOrDefault("OIDC_FIXTURE_SUBJECT", strconv.FormatInt(424242, 10)), | ||
| 24 | + }) | ||
| 25 | + if err != nil { | ||
| 26 | + logger.Error("OIDC fixture configuration failed", "error", err) | ||
| 27 | + os.Exit(1) | ||
| 28 | + } | ||
| 29 | + httpServer := &http.Server{ | ||
| 30 | + Addr: ":" + port, Handler: server, ReadHeaderTimeout: 5 * time.Second, | ||
| 31 | + ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, | ||
| 32 | + } | ||
| 33 | + logger.Info("starting local OIDC fixture", "address", httpServer.Addr) | ||
| 34 | + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 35 | + logger.Error("OIDC fixture stopped", "error", err) | ||
| 36 | + os.Exit(1) | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func envOrDefault(name, fallback string) string { | ||
| 41 | + if value := os.Getenv(name); value != "" { | ||
| 42 | + return value | ||
| 43 | + } | ||
| 44 | + return fallback | ||
| 45 | +} | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bufio" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + "io" | ||
| 10 | + "os" | ||
| 11 | + "strings" | ||
| 12 | + "time" | ||
| 13 | + | ||
| 14 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 15 | + "gitcode.com/urandon/sessionless/internal/ydbclient" | ||
| 16 | + "gitcode.com/urandon/sessionless/internal/ydbstore" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +func main() { | ||
| 20 | + if len(os.Args) != 1 { | ||
| 21 | + fatal(errors.New("web bootstrap accepts no command-line arguments; use documented environment values and stdin confirmation")) | ||
| 22 | + } | ||
| 23 | + grant, err := grantFromEnvironment(time.Now().UTC()) | ||
| 24 | + if err != nil { | ||
| 25 | + fatal(err) | ||
| 26 | + } | ||
| 27 | + expected := fmt.Sprintf("BOOTSTRAP %s INTO %s", grant.UserID, grant.TenantID) | ||
| 28 | + fmt.Fprintf(os.Stderr, "Type %q to create the audited cloud-dev membership: ", expected) | ||
| 29 | + confirmation, err := bufio.NewReader(os.Stdin).ReadString('\n') | ||
| 30 | + if err != nil && !errors.Is(err, io.EOF) { | ||
| 31 | + fatal(fmt.Errorf("read confirmation: %w", err)) | ||
| 32 | + } | ||
| 33 | + if strings.TrimSpace(confirmation) != expected { | ||
| 34 | + fatal(errors.New("confirmation did not match; no membership was changed")) | ||
| 35 | + } | ||
| 36 | + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 37 | + defer cancel() | ||
| 38 | + client, err := ydbclient.Open(ctx, os.Getenv("YDB_CONNECTION_STRING")) | ||
| 39 | + if err != nil { | ||
| 40 | + fatal(fmt.Errorf("open YDB: %w", err)) | ||
| 41 | + } | ||
| 42 | + defer client.Close(context.Background()) | ||
| 43 | + store, err := ydbstore.New(client.DB, ydbstore.Options{}) | ||
| 44 | + if err != nil { | ||
| 45 | + fatal(err) | ||
| 46 | + } | ||
| 47 | + membership, err := store.BootstrapDevelopmentMembership(ctx, grant) | ||
| 48 | + if err != nil { | ||
| 49 | + fatal(err) | ||
| 50 | + } | ||
| 51 | + if err := json.NewEncoder(os.Stdout).Encode(map[string]any{ | ||
| 52 | + "status": "ready", "tenant_id": membership.TenantID, "user_id": membership.UserID, | ||
| 53 | + "role": membership.Role, "security_version": membership.SecurityVersion, | ||
| 54 | + }); err != nil { | ||
| 55 | + fatal(err) | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +func grantFromEnvironment(now time.Time) (domain.DevelopmentBootstrapGrant, error) { | ||
| 60 | + role := domain.TenantMembershipRole(os.Getenv("WEB_BOOTSTRAP_ROLE")) | ||
| 61 | + grant := domain.DevelopmentBootstrapGrant{ | ||
| 62 | + TenantID: domain.TenantID(os.Getenv("WEB_BOOTSTRAP_TENANT_ID")), | ||
| 63 | + UserID: domain.UserID(os.Getenv("WEB_BOOTSTRAP_USER_ID")), | ||
| 64 | + Role: role, Environment: os.Getenv("SESSIONLESS_ENVIRONMENT"), | ||
| 65 | + Operator: os.Getenv("WEB_BOOTSTRAP_OPERATOR"), Reason: os.Getenv("WEB_BOOTSTRAP_REASON"), | ||
| 66 | + GrantedAt: now, | ||
| 67 | + } | ||
| 68 | + if err := grant.Validate(); err != nil { | ||
| 69 | + return domain.DevelopmentBootstrapGrant{}, err | ||
| 70 | + } | ||
| 71 | + if os.Getenv("YDB_CONNECTION_STRING") == "" { | ||
| 72 | + return domain.DevelopmentBootstrapGrant{}, errors.New("YDB_CONNECTION_STRING is required") | ||
| 73 | + } | ||
| 74 | + return grant, nil | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +func fatal(err error) { | ||
| 78 | + fmt.Fprintln(os.Stderr, "web bootstrap failed:", err) | ||
| 79 | + os.Exit(1) | ||
| 80 | +} | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "testing" | ||
| 5 | + "time" | ||
| 6 | + | ||
| 7 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func TestGrantFromEnvironmentRequiresAuditedCloudDevelopmentInput(t *testing.T) { | ||
| 11 | + now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) | ||
| 12 | + setGrantEnvironment(t) | ||
| 13 | + grant, err := grantFromEnvironment(now) | ||
| 14 | + if err != nil { | ||
| 15 | + t.Fatal(err) | ||
| 16 | + } | ||
| 17 | + if grant.TenantID != "ten_alpha" || grant.UserID != "usr_known_user" || | ||
| 18 | + grant.Role != domain.TenantMembershipOwner || grant.GrantedAt != now { | ||
| 19 | + t.Fatalf("grant = %+v", grant) | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + t.Setenv("SESSIONLESS_ENVIRONMENT", "production") | ||
| 23 | + if _, err := grantFromEnvironment(now); err == nil { | ||
| 24 | + t.Fatal("production bootstrap unexpectedly passed validation") | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func TestGrantFromEnvironmentRequiresConnectionCoordinates(t *testing.T) { | ||
| 29 | + setGrantEnvironment(t) | ||
| 30 | + t.Setenv("YDB_CONNECTION_STRING", "") | ||
| 31 | + if _, err := grantFromEnvironment(time.Now().UTC()); err == nil { | ||
| 32 | + t.Fatal("bootstrap without YDB coordinates unexpectedly passed validation") | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +func setGrantEnvironment(t *testing.T) { | ||
| 37 | + t.Helper() | ||
| 38 | + t.Setenv("WEB_BOOTSTRAP_TENANT_ID", "ten_alpha") | ||
| 39 | + t.Setenv("WEB_BOOTSTRAP_USER_ID", "usr_known_user") | ||
| 40 | + t.Setenv("WEB_BOOTSTRAP_ROLE", string(domain.TenantMembershipOwner)) | ||
| 41 | + t.Setenv("SESSIONLESS_ENVIRONMENT", domain.DevelopmentEnvironment) | ||
| 42 | + t.Setenv("WEB_BOOTSTRAP_OPERATOR", "operator@example.com") | ||
| 43 | + t.Setenv("WEB_BOOTSTRAP_REASON", "initial Web access") | ||
| 44 | + t.Setenv("YDB_CONNECTION_STRING", "grpcs://example.invalid/local") | ||
| 45 | +} | ||
| @@ -1,8 +1,8 @@ | |||
| 1 | # Web authentication and API contracts | 1 | # Web authentication and API contracts |
| 2 | 2 | ||
| 3 | -This document freezes the WEB-01 contracts. It is an implementation input for | 3 | +This document freezes the WEB-01 contracts. WEB-02 now implements the Go BFF, |
| 4 | -WEB-02 and WEB-03; it does not claim that the Web BFF, YDB auth tables, or | 4 | +Telegram OIDC adapter, YDB auth tables, and operator bootstrap described here. |
| 5 | -browser application already exist. | 5 | +The canonical resource API and browser application remain WEB-03 and WEB-04. |
| 6 | 6 | ||
| 7 | The WebUI is a projection over canonical Sessionless sessions and events. | 7 | The WebUI is a projection over canonical Sessionless sessions and events. |
| 8 | Telegram is the first identity provider for the WebUI, but Telegram chats, | 8 | Telegram is the first identity provider for the WebUI, but Telegram chats, |
| @@ -121,8 +121,8 @@ expiry, role, and optional provider/subject restriction. Consumption and | |||
| 121 | membership creation are one serializable transaction; expiry, subject mismatch, | 121 | membership creation are one serializable transaction; expiry, subject mismatch, |
| 122 | replay, and competing consumption fail closed. | 122 | replay, and competing consumption fail closed. |
| 123 | 123 | ||
| 124 | -The development bootstrap exists only for `cloud-dev`. WEB-02 must expose it as | 124 | +The development bootstrap exists only for `cloud-dev`. `make web-bootstrap` |
| 125 | -an operator-only binary or Make-backed target with these requirements: | 125 | +exposes it as an operator-only command with these requirements: |
| 126 | 126 | ||
| 127 | - use the normal YDB metadata/environment credential chain; never accept a YDB | 127 | - use the normal YDB metadata/environment credential chain; never accept a YDB |
| 128 | IAM token, invitation secret, or service-account key on the command line; | 128 | IAM token, invitation secret, or service-account key on the command line; |
| @@ -2,9 +2,9 @@ | |||
| 2 | 2 | ||
| 3 | ## Scope and assets | 3 | ## Scope and assets |
| 4 | 4 | ||
| 5 | -This model covers the future same-origin WebUI, Go BFF, Telegram OIDC callback, | 5 | +This model covers the implemented Go BFF, Telegram OIDC callback, and YDB auth |
| 6 | -YDB auth records, canonical-session API, and direct-to-Object-Storage upload | 6 | +records, plus the planned same-origin WebUI, canonical-session API, and |
| 7 | -flow. Telegram message ingress, worker sandbox escape, provider-subscription | 7 | +direct-to-Object-Storage upload flow. Telegram message ingress, worker sandbox escape, provider-subscription |
| 8 | automation, and general tenant administration are covered by their own tracks. | 8 | automation, and general tenant administration are covered by their own tracks. |
| 9 | 9 | ||
| 10 | Protected assets are tenant memberships, canonical sessions/events, uploaded | 10 | Protected assets are tenant memberships, canonical sessions/events, uploaded |
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | github.com/aws/aws-sdk-go-v2/credentials v1.19.31 | 10 | github.com/aws/aws-sdk-go-v2/credentials v1.19.31 |
| 11 | github.com/aws/aws-sdk-go-v2/service/s3 v1.106.1 | 11 | github.com/aws/aws-sdk-go-v2/service/s3 v1.106.1 |
| 12 | github.com/aws/aws-sdk-go-v2/service/sqs v1.46.1 | 12 | github.com/aws/aws-sdk-go-v2/service/sqs v1.46.1 |
| 13 | + github.com/golang-jwt/jwt/v4 v4.5.2 | ||
| 13 | github.com/pressly/goose/v3 v3.27.1 | 14 | github.com/pressly/goose/v3 v3.27.1 |
| 14 | github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.2 | 15 | github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.2 |
| 15 | github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0 | 16 | github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0 |
| @@ -31,7 +32,6 @@ require ( | |||
| 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.1 // indirect | 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.1 // indirect |
| 32 | github.com/aws/aws-sdk-go-v2/service/sts v1.45.1 // indirect | 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.45.1 // indirect |
| 33 | github.com/aws/smithy-go v1.27.5 // indirect | 34 | github.com/aws/smithy-go v1.27.5 // indirect |
| 34 | - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect | ||
| 35 | github.com/google/uuid v1.6.0 // indirect | 35 | github.com/google/uuid v1.6.0 // indirect |
| 36 | github.com/jonboulle/clockwork v0.5.0 // indirect | 36 | github.com/jonboulle/clockwork v0.5.0 // indirect |
| 37 | github.com/mfridman/interpolate v0.0.2 // indirect | 37 | github.com/mfridman/interpolate v0.0.2 // indirect |
| @@ -461,6 +461,72 @@ type OIDCIdentityClaims struct { | |||
| 461 | ExpiresAt time.Time | 461 | ExpiresAt time.Time |
| 462 | } | 462 | } |
| 463 | 463 | ||
| 464 | +type WebSecurityAuditAction string | ||
| 465 | + | ||
| 466 | +const ( | ||
| 467 | + WebSecurityLoginFailed WebSecurityAuditAction = "web.login.failed" | ||
| 468 | + WebSecurityCSRFRejected WebSecurityAuditAction = "web.csrf.rejected" | ||
| 469 | +) | ||
| 470 | + | ||
| 471 | +// WebSecurityAuditEvent is the redacted, durable record for authentication | ||
| 472 | +// failures that do not necessarily have a tenant-scoped audit destination. | ||
| 473 | +// SubjectFingerprint is a one-way digest of the verified provider subject; | ||
| 474 | +// raw claims and browser credentials never cross this boundary. | ||
| 475 | +type WebSecurityAuditEvent struct { | ||
| 476 | + RequestID string `json:"request_id"` | ||
| 477 | + Action WebSecurityAuditAction `json:"action"` | ||
| 478 | + Provider IdentityProvider `json:"provider"` | ||
| 479 | + SubjectFingerprint SecretDigest `json:"subject_fingerprint,omitempty"` | ||
| 480 | + TenantID TenantID `json:"tenant_id,omitempty"` | ||
| 481 | + UserID UserID `json:"user_id,omitempty"` | ||
| 482 | + MembershipSecurityVersion uint64 `json:"membership_security_version,omitempty"` | ||
| 483 | + ReasonCode string `json:"reason_code"` | ||
| 484 | + OccurredAt time.Time `json:"occurred_at"` | ||
| 485 | +} | ||
| 486 | + | ||
| 487 | +func (event WebSecurityAuditEvent) Validate() error { | ||
| 488 | + if err := ValidateOpaqueID("web_security_audit.request_id", event.RequestID); err != nil { | ||
| 489 | + return err | ||
| 490 | + } | ||
| 491 | + switch event.Action { | ||
| 492 | + case WebSecurityLoginFailed, WebSecurityCSRFRejected: | ||
| 493 | + default: | ||
| 494 | + return ValidationError{Field: "web_security_audit.action", Reason: "is unknown"} | ||
| 495 | + } | ||
| 496 | + if err := event.Provider.Validate(); err != nil { | ||
| 497 | + return err | ||
| 498 | + } | ||
| 499 | + if event.SubjectFingerprint != "" { | ||
| 500 | + if err := event.SubjectFingerprint.Validate("web_security_audit.subject_fingerprint"); err != nil { | ||
| 501 | + return err | ||
| 502 | + } | ||
| 503 | + } | ||
| 504 | + if event.TenantID != "" { | ||
| 505 | + if err := event.TenantID.Validate(); err != nil { | ||
| 506 | + return err | ||
| 507 | + } | ||
| 508 | + } | ||
| 509 | + if event.UserID != "" { | ||
| 510 | + if err := event.UserID.Validate(); err != nil { | ||
| 511 | + return err | ||
| 512 | + } | ||
| 513 | + } | ||
| 514 | + if event.MembershipSecurityVersion > 0 && (event.TenantID == "" || event.UserID == "") { | ||
| 515 | + return ValidationError{Field: "web_security_audit.membership_security_version", Reason: "requires tenant and user"} | ||
| 516 | + } | ||
| 517 | + if err := ValidateOpaqueID("web_security_audit.reason_code", event.ReasonCode); err != nil { | ||
| 518 | + return err | ||
| 519 | + } | ||
| 520 | + if event.OccurredAt.IsZero() { | ||
| 521 | + return ValidationError{Field: "web_security_audit.occurred_at", Reason: "must not be zero"} | ||
| 522 | + } | ||
| 523 | + if event.Action == WebSecurityCSRFRejected && | ||
| 524 | + (event.TenantID == "" || event.UserID == "" || event.MembershipSecurityVersion == 0) { | ||
| 525 | + return ValidationError{Field: "web_security_audit.csrf", Reason: "requires authorized tenant, user, and membership version"} | ||
| 526 | + } | ||
| 527 | + return nil | ||
| 528 | +} | ||
| 529 | + | ||
| 464 | // Verify checks claims only after an OIDC adapter has verified the JWT | 530 | // Verify checks claims only after an OIDC adapter has verified the JWT |
| 465 | // signature against a bounded JWKS cache and an allowed algorithm. | 531 | // signature against a bounded JWKS cache and an allowed algorithm. |
| 466 | func (claims OIDCIdentityClaims) Verify(policy OIDCVerificationPolicy, expectedNonce string, now time.Time) error { | 532 | func (claims OIDCIdentityClaims) Verify(policy OIDCVerificationPolicy, expectedNonce string, now time.Time) error { |
| @@ -0,0 +1,212 @@ | |||
| 1 | +// Package oidcfixture provides a local-only Telegram-shaped OIDC server for | ||
| 2 | +// deterministic integration tests. It must never be enabled in cloud modes. | ||
| 3 | +package oidcfixture | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "crypto/rand" | ||
| 7 | + "crypto/rsa" | ||
| 8 | + "crypto/sha256" | ||
| 9 | + "crypto/subtle" | ||
| 10 | + "encoding/base64" | ||
| 11 | + "encoding/json" | ||
| 12 | + "errors" | ||
| 13 | + "fmt" | ||
| 14 | + "io" | ||
| 15 | + "math/big" | ||
| 16 | + "net/http" | ||
| 17 | + "net/url" | ||
| 18 | + "strings" | ||
| 19 | + "sync" | ||
| 20 | + "time" | ||
| 21 | + | ||
| 22 | + "github.com/golang-jwt/jwt/v4" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +const keyID = "sessionless-local-rs256" | ||
| 26 | + | ||
| 27 | +type Config struct { | ||
| 28 | + Environment string | ||
| 29 | + Issuer string | ||
| 30 | + ClientID string | ||
| 31 | + ClientSecret string | ||
| 32 | + RedirectURI string | ||
| 33 | + Subject string | ||
| 34 | + Now func() time.Time | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +type Server struct { | ||
| 38 | + config Config | ||
| 39 | + key *rsa.PrivateKey | ||
| 40 | + mux *http.ServeMux | ||
| 41 | + mu sync.Mutex | ||
| 42 | + codes map[string]authorization | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +type authorization struct { | ||
| 46 | + challenge string | ||
| 47 | + nonce string | ||
| 48 | + redirectURI string | ||
| 49 | + expiresAt time.Time | ||
| 50 | + consumed bool | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +func New(config Config) (*Server, error) { | ||
| 54 | + if config.Environment != "local" { | ||
| 55 | + return nil, errors.New("OIDC fixture refuses every environment except local") | ||
| 56 | + } | ||
| 57 | + if config.Issuer == "" || config.ClientID == "" || config.ClientSecret == "" || config.RedirectURI == "" || config.Subject == "" { | ||
| 58 | + return nil, errors.New("OIDC fixture issuer, client, secret, redirect URI, and subject are required") | ||
| 59 | + } | ||
| 60 | + issuer, err := url.Parse(config.Issuer) | ||
| 61 | + if err != nil { | ||
| 62 | + return nil, errors.New("OIDC fixture issuer must be a loopback origin") | ||
| 63 | + } | ||
| 64 | + hostname := issuer.Hostname() | ||
| 65 | + if (issuer.Scheme != "http" && issuer.Scheme != "https") || | ||
| 66 | + (hostname != "127.0.0.1" && hostname != "localhost" && hostname != "::1" && !strings.HasSuffix(hostname, ".localhost")) { | ||
| 67 | + return nil, errors.New("OIDC fixture issuer must be a loopback origin") | ||
| 68 | + } | ||
| 69 | + if config.Now == nil { | ||
| 70 | + config.Now = time.Now | ||
| 71 | + } | ||
| 72 | + key, err := rsa.GenerateKey(rand.Reader, 2048) | ||
| 73 | + if err != nil { | ||
| 74 | + return nil, fmt.Errorf("generate fixture signing key: %w", err) | ||
| 75 | + } | ||
| 76 | + server := &Server{config: config, key: key, mux: http.NewServeMux(), codes: make(map[string]authorization)} | ||
| 77 | + server.routes() | ||
| 78 | + return server, nil | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +func (server *Server) ServeHTTP(w http.ResponseWriter, request *http.Request) { | ||
| 82 | + w.Header().Set("Cache-Control", "no-store") | ||
| 83 | + w.Header().Set("X-Content-Type-Options", "nosniff") | ||
| 84 | + server.mux.ServeHTTP(w, request) | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +func (server *Server) routes() { | ||
| 88 | + server.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { | ||
| 89 | + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) | ||
| 90 | + }) | ||
| 91 | + server.mux.HandleFunc("GET /.well-known/openid-configuration", server.discovery) | ||
| 92 | + server.mux.HandleFunc("GET /.well-known/jwks.json", server.jwks) | ||
| 93 | + server.mux.HandleFunc("GET /auth", server.authorize) | ||
| 94 | + server.mux.HandleFunc("POST /token", server.token) | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func (server *Server) discovery(w http.ResponseWriter, _ *http.Request) { | ||
| 98 | + writeJSON(w, http.StatusOK, map[string]any{ | ||
| 99 | + "issuer": server.config.Issuer, | ||
| 100 | + "authorization_endpoint": server.config.Issuer + "/auth", | ||
| 101 | + "token_endpoint": server.config.Issuer + "/token", | ||
| 102 | + "jwks_uri": server.config.Issuer + "/.well-known/jwks.json", | ||
| 103 | + "response_types_supported": []string{"code"}, | ||
| 104 | + "subject_types_supported": []string{"public"}, | ||
| 105 | + "id_token_signing_alg_values_supported": []string{"RS256"}, | ||
| 106 | + "code_challenge_methods_supported": []string{"S256"}, | ||
| 107 | + }) | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +func (server *Server) jwks(w http.ResponseWriter, _ *http.Request) { | ||
| 111 | + public := server.key.PublicKey | ||
| 112 | + exponent := big.NewInt(int64(public.E)).Bytes() | ||
| 113 | + writeJSON(w, http.StatusOK, map[string]any{"keys": []map[string]string{{ | ||
| 114 | + "kty": "RSA", "use": "sig", "alg": "RS256", "kid": keyID, | ||
| 115 | + "n": base64.RawURLEncoding.EncodeToString(public.N.Bytes()), | ||
| 116 | + "e": base64.RawURLEncoding.EncodeToString(exponent), | ||
| 117 | + }}}) | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +func (server *Server) authorize(w http.ResponseWriter, request *http.Request) { | ||
| 121 | + query := request.URL.Query() | ||
| 122 | + if query.Get("client_id") != server.config.ClientID || query.Get("redirect_uri") != server.config.RedirectURI || | ||
| 123 | + query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || | ||
| 124 | + query.Get("state") == "" || query.Get("nonce") == "" || query.Get("code_challenge") == "" || | ||
| 125 | + query.Get("scope") != "openid profile" { | ||
| 126 | + http.Error(w, "invalid authorization request", http.StatusBadRequest) | ||
| 127 | + return | ||
| 128 | + } | ||
| 129 | + code, err := randomValue(32) | ||
| 130 | + if err != nil { | ||
| 131 | + http.Error(w, "fixture unavailable", http.StatusServiceUnavailable) | ||
| 132 | + return | ||
| 133 | + } | ||
| 134 | + server.mu.Lock() | ||
| 135 | + server.codes[code] = authorization{ | ||
| 136 | + challenge: query.Get("code_challenge"), nonce: query.Get("nonce"), | ||
| 137 | + redirectURI: query.Get("redirect_uri"), expiresAt: server.config.Now().UTC().Add(time.Minute), | ||
| 138 | + } | ||
| 139 | + server.mu.Unlock() | ||
| 140 | + redirect, _ := url.Parse(server.config.RedirectURI) | ||
| 141 | + values := redirect.Query() | ||
| 142 | + values.Set("code", code) | ||
| 143 | + values.Set("state", query.Get("state")) | ||
| 144 | + redirect.RawQuery = values.Encode() | ||
| 145 | + w.Header().Set("Location", redirect.String()) | ||
| 146 | + w.WriteHeader(http.StatusSeeOther) | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +func (server *Server) token(w http.ResponseWriter, request *http.Request) { | ||
| 150 | + clientID, secret, ok := request.BasicAuth() | ||
| 151 | + if !ok || subtle.ConstantTimeCompare([]byte(clientID), []byte(server.config.ClientID)) != 1 || | ||
| 152 | + subtle.ConstantTimeCompare([]byte(secret), []byte(server.config.ClientSecret)) != 1 { | ||
| 153 | + http.Error(w, "invalid client", http.StatusUnauthorized) | ||
| 154 | + return | ||
| 155 | + } | ||
| 156 | + request.Body = http.MaxBytesReader(w, request.Body, 64<<10) | ||
| 157 | + if err := request.ParseForm(); err != nil || request.Form.Get("grant_type") != "authorization_code" || | ||
| 158 | + request.Form.Get("client_id") != server.config.ClientID || request.Form.Get("redirect_uri") != server.config.RedirectURI { | ||
| 159 | + http.Error(w, "invalid token request", http.StatusBadRequest) | ||
| 160 | + return | ||
| 161 | + } | ||
| 162 | + code := request.Form.Get("code") | ||
| 163 | + server.mu.Lock() | ||
| 164 | + authorization, found := server.codes[code] | ||
| 165 | + if !found || authorization.consumed || | ||
| 166 | + !server.config.Now().UTC().Before(authorization.expiresAt) || authorization.redirectURI != request.Form.Get("redirect_uri") { | ||
| 167 | + server.mu.Unlock() | ||
| 168 | + http.Error(w, "invalid authorization code", http.StatusBadRequest) | ||
| 169 | + return | ||
| 170 | + } | ||
| 171 | + digest := sha256.Sum256([]byte(request.Form.Get("code_verifier"))) | ||
| 172 | + actualChallenge := base64.RawURLEncoding.EncodeToString(digest[:]) | ||
| 173 | + if subtle.ConstantTimeCompare([]byte(actualChallenge), []byte(authorization.challenge)) != 1 { | ||
| 174 | + server.mu.Unlock() | ||
| 175 | + http.Error(w, "invalid PKCE verifier", http.StatusBadRequest) | ||
| 176 | + return | ||
| 177 | + } | ||
| 178 | + authorization.consumed = true | ||
| 179 | + server.codes[code] = authorization | ||
| 180 | + server.mu.Unlock() | ||
| 181 | + now := server.config.Now().UTC() | ||
| 182 | + claims := jwt.MapClaims{ | ||
| 183 | + "iss": server.config.Issuer, "aud": server.config.ClientID, "sub": server.config.Subject, | ||
| 184 | + "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), "nonce": authorization.nonce, | ||
| 185 | + "name": "Sessionless Local User", "preferred_username": "sessionless-local", | ||
| 186 | + } | ||
| 187 | + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) | ||
| 188 | + token.Header["kid"] = keyID | ||
| 189 | + signed, err := token.SignedString(server.key) | ||
| 190 | + if err != nil { | ||
| 191 | + http.Error(w, "fixture signing failed", http.StatusServiceUnavailable) | ||
| 192 | + return | ||
| 193 | + } | ||
| 194 | + writeJSON(w, http.StatusOK, map[string]any{ | ||
| 195 | + "access_token": "fixture-access-token", "token_type": "Bearer", | ||
| 196 | + "expires_in": 3600, "id_token": signed, | ||
| 197 | + }) | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +func randomValue(size int) (string, error) { | ||
| 201 | + value := make([]byte, size) | ||
| 202 | + if _, err := io.ReadFull(rand.Reader, value); err != nil { | ||
| 203 | + return "", err | ||
| 204 | + } | ||
| 205 | + return base64.RawURLEncoding.EncodeToString(value), nil | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +func writeJSON(w http.ResponseWriter, status int, value any) { | ||
| 209 | + w.Header().Set("Content-Type", "application/json") | ||
| 210 | + w.WriteHeader(status) | ||
| 211 | + _ = json.NewEncoder(w).Encode(value) | ||
| 212 | +} | ||
| @@ -0,0 +1,324 @@ | |||
| 1 | +// Package telegramoidc implements the server-side Telegram OpenID Connect | ||
| 2 | +// adapter. Provider tokens remain inside this package and are never returned | ||
| 3 | +// through the ports.OIDCProvider boundary. | ||
| 4 | +package telegramoidc | ||
| 5 | + | ||
| 6 | +import ( | ||
| 7 | + "context" | ||
| 8 | + "crypto/rsa" | ||
| 9 | + "encoding/base64" | ||
| 10 | + "encoding/json" | ||
| 11 | + "errors" | ||
| 12 | + "fmt" | ||
| 13 | + "io" | ||
| 14 | + "math/big" | ||
| 15 | + "net/http" | ||
| 16 | + "net/url" | ||
| 17 | + "strconv" | ||
| 18 | + "strings" | ||
| 19 | + "sync" | ||
| 20 | + "time" | ||
| 21 | + | ||
| 22 | + "github.com/golang-jwt/jwt/v4" | ||
| 23 | + | ||
| 24 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 25 | + "gitcode.com/urandon/sessionless/internal/ports" | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +const ( | ||
| 29 | + DefaultIssuer = "https://oauth.telegram.org" | ||
| 30 | + DefaultAuthorizationEndpoint = "https://oauth.telegram.org/auth" | ||
| 31 | + DefaultTokenEndpoint = "https://oauth.telegram.org/token" | ||
| 32 | + DefaultJWKSURL = "https://oauth.telegram.org/.well-known/jwks.json" | ||
| 33 | + defaultJWKSCacheTTL = 10 * time.Minute | ||
| 34 | + maxProviderResponseBytes = 1 << 20 | ||
| 35 | +) | ||
| 36 | + | ||
| 37 | +var ErrProviderResponse = errors.New("Telegram OIDC provider response is invalid") | ||
| 38 | + | ||
| 39 | +type Config struct { | ||
| 40 | + Issuer string | ||
| 41 | + AuthorizationEndpoint string | ||
| 42 | + TokenEndpoint string | ||
| 43 | + JWKSURL string | ||
| 44 | + ClientID string | ||
| 45 | + ClientSecret string | ||
| 46 | + RedirectURI string | ||
| 47 | + AllowedAlgorithms []string | ||
| 48 | + JWKSCacheTTL time.Duration | ||
| 49 | + HTTPClient *http.Client | ||
| 50 | + AllowLoopbackProvider bool | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +type Provider struct { | ||
| 54 | + config Config | ||
| 55 | + client *http.Client | ||
| 56 | + keys jwksCache | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +type jwksCache struct { | ||
| 60 | + mu sync.Mutex | ||
| 61 | + keys map[string]any | ||
| 62 | + expiresAt time.Time | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func New(config Config) (*Provider, error) { | ||
| 66 | + if config.Issuer == "" { | ||
| 67 | + config.Issuer = DefaultIssuer | ||
| 68 | + } | ||
| 69 | + if config.AuthorizationEndpoint == "" { | ||
| 70 | + config.AuthorizationEndpoint = DefaultAuthorizationEndpoint | ||
| 71 | + } | ||
| 72 | + if config.TokenEndpoint == "" { | ||
| 73 | + config.TokenEndpoint = DefaultTokenEndpoint | ||
| 74 | + } | ||
| 75 | + if config.JWKSURL == "" { | ||
| 76 | + config.JWKSURL = DefaultJWKSURL | ||
| 77 | + } | ||
| 78 | + if len(config.AllowedAlgorithms) == 0 { | ||
| 79 | + config.AllowedAlgorithms = []string{"RS256"} | ||
| 80 | + } | ||
| 81 | + if config.JWKSCacheTTL <= 0 || config.JWKSCacheTTL > defaultJWKSCacheTTL { | ||
| 82 | + config.JWKSCacheTTL = defaultJWKSCacheTTL | ||
| 83 | + } | ||
| 84 | + if config.HTTPClient == nil { | ||
| 85 | + config.HTTPClient = &http.Client{Timeout: 10 * time.Second} | ||
| 86 | + } | ||
| 87 | + if err := validateConfig(config); err != nil { | ||
| 88 | + return nil, err | ||
| 89 | + } | ||
| 90 | + return &Provider{config: config, client: config.HTTPClient}, nil | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +func (provider *Provider) AuthorizationURL( | ||
| 94 | + _ context.Context, | ||
| 95 | + request ports.OIDCAuthorizationRequest, | ||
| 96 | +) (string, error) { | ||
| 97 | + if err := request.Validate(); err != nil { | ||
| 98 | + return "", err | ||
| 99 | + } | ||
| 100 | + if request.Provider != domain.IdentityProviderTelegram { | ||
| 101 | + return "", domain.ValidationError{Field: "oidc.provider", Reason: "must be telegram"} | ||
| 102 | + } | ||
| 103 | + if request.RedirectURI != provider.config.RedirectURI { | ||
| 104 | + return "", domain.ValidationError{Field: "oidc.redirect_uri", Reason: "does not match the configured callback"} | ||
| 105 | + } | ||
| 106 | + endpoint, err := url.Parse(provider.config.AuthorizationEndpoint) | ||
| 107 | + if err != nil { | ||
| 108 | + return "", err | ||
| 109 | + } | ||
| 110 | + query := endpoint.Query() | ||
| 111 | + query.Set("client_id", provider.config.ClientID) | ||
| 112 | + query.Set("redirect_uri", request.RedirectURI) | ||
| 113 | + query.Set("response_type", "code") | ||
| 114 | + query.Set("scope", strings.Join(request.Scopes, " ")) | ||
| 115 | + query.Set("state", request.State) | ||
| 116 | + query.Set("nonce", request.Nonce) | ||
| 117 | + query.Set("code_challenge", request.CodeChallenge) | ||
| 118 | + query.Set("code_challenge_method", "S256") | ||
| 119 | + endpoint.RawQuery = query.Encode() | ||
| 120 | + return endpoint.String(), nil | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +func (provider *Provider) ExchangeAndVerify( | ||
| 124 | + ctx context.Context, | ||
| 125 | + request ports.OIDCTokenRequest, | ||
| 126 | +) (domain.OIDCIdentityClaims, error) { | ||
| 127 | + if err := request.Validate(); err != nil { | ||
| 128 | + return domain.OIDCIdentityClaims{}, err | ||
| 129 | + } | ||
| 130 | + if request.Provider != domain.IdentityProviderTelegram || request.RedirectURI != provider.config.RedirectURI { | ||
| 131 | + return domain.OIDCIdentityClaims{}, ErrProviderResponse | ||
| 132 | + } | ||
| 133 | + form := url.Values{ | ||
| 134 | + "grant_type": {"authorization_code"}, | ||
| 135 | + "code": {request.Code}, | ||
| 136 | + "redirect_uri": {request.RedirectURI}, | ||
| 137 | + "client_id": {provider.config.ClientID}, | ||
| 138 | + "code_verifier": {request.PKCEVerifier}, | ||
| 139 | + } | ||
| 140 | + httpRequest, err := http.NewRequestWithContext( | ||
| 141 | + ctx, http.MethodPost, provider.config.TokenEndpoint, strings.NewReader(form.Encode()), | ||
| 142 | + ) | ||
| 143 | + if err != nil { | ||
| 144 | + return domain.OIDCIdentityClaims{}, ErrProviderResponse | ||
| 145 | + } | ||
| 146 | + httpRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 147 | + httpRequest.SetBasicAuth(provider.config.ClientID, provider.config.ClientSecret) | ||
| 148 | + response, err := provider.client.Do(httpRequest) | ||
| 149 | + if err != nil { | ||
| 150 | + return domain.OIDCIdentityClaims{}, fmt.Errorf("%w: token exchange failed", ErrProviderResponse) | ||
| 151 | + } | ||
| 152 | + defer response.Body.Close() | ||
| 153 | + if response.StatusCode != http.StatusOK { | ||
| 154 | + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxProviderResponseBytes)) | ||
| 155 | + return domain.OIDCIdentityClaims{}, fmt.Errorf("%w: token endpoint status %d", ErrProviderResponse, response.StatusCode) | ||
| 156 | + } | ||
| 157 | + var tokens struct { | ||
| 158 | + IDToken string `json:"id_token"` | ||
| 159 | + } | ||
| 160 | + decoder := json.NewDecoder(io.LimitReader(response.Body, maxProviderResponseBytes)) | ||
| 161 | + if err := decoder.Decode(&tokens); err != nil || tokens.IDToken == "" { | ||
| 162 | + return domain.OIDCIdentityClaims{}, ErrProviderResponse | ||
| 163 | + } | ||
| 164 | + claims, err := provider.verifyIDToken(ctx, tokens.IDToken, request.Now) | ||
| 165 | + if err != nil { | ||
| 166 | + return domain.OIDCIdentityClaims{}, err | ||
| 167 | + } | ||
| 168 | + if err := claims.Verify(request.Policy, request.ExpectedNonce, request.Now); err != nil { | ||
| 169 | + return domain.OIDCIdentityClaims{}, err | ||
| 170 | + } | ||
| 171 | + return claims, nil | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +type telegramClaims struct { | ||
| 175 | + jwt.RegisteredClaims | ||
| 176 | + Nonce string `json:"nonce"` | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +func (provider *Provider) verifyIDToken( | ||
| 180 | + ctx context.Context, | ||
| 181 | + rawToken string, | ||
| 182 | + now time.Time, | ||
| 183 | +) (domain.OIDCIdentityClaims, error) { | ||
| 184 | + parser := &jwt.Parser{ | ||
| 185 | + ValidMethods: append([]string(nil), provider.config.AllowedAlgorithms...), | ||
| 186 | + SkipClaimsValidation: true, | ||
| 187 | + } | ||
| 188 | + claims := &telegramClaims{} | ||
| 189 | + token, err := parser.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (any, error) { | ||
| 190 | + kid, ok := token.Header["kid"].(string) | ||
| 191 | + if !ok || kid == "" { | ||
| 192 | + return nil, ErrProviderResponse | ||
| 193 | + } | ||
| 194 | + return provider.signingKey(ctx, kid, now) | ||
| 195 | + }) | ||
| 196 | + if err != nil || token == nil || !token.Valid { | ||
| 197 | + return domain.OIDCIdentityClaims{}, fmt.Errorf("%w: ID token verification failed", ErrProviderResponse) | ||
| 198 | + } | ||
| 199 | + if claims.IssuedAt == nil || claims.ExpiresAt == nil { | ||
| 200 | + return domain.OIDCIdentityClaims{}, ErrProviderResponse | ||
| 201 | + } | ||
| 202 | + return domain.OIDCIdentityClaims{ | ||
| 203 | + Issuer: claims.Issuer, Audience: []string(claims.Audience), Subject: claims.Subject, | ||
| 204 | + Nonce: claims.Nonce, IssuedAt: claims.IssuedAt.Time.UTC(), ExpiresAt: claims.ExpiresAt.Time.UTC(), | ||
| 205 | + }, nil | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +func (provider *Provider) signingKey(ctx context.Context, kid string, now time.Time) (any, error) { | ||
| 209 | + provider.keys.mu.Lock() | ||
| 210 | + defer provider.keys.mu.Unlock() | ||
| 211 | + if now.Before(provider.keys.expiresAt) { | ||
| 212 | + if key, found := provider.keys.keys[kid]; found { | ||
| 213 | + return key, nil | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + if err := provider.refreshKeys(ctx, now); err != nil { | ||
| 217 | + return nil, err | ||
| 218 | + } | ||
| 219 | + key, found := provider.keys.keys[kid] | ||
| 220 | + if !found { | ||
| 221 | + return nil, ErrProviderResponse | ||
| 222 | + } | ||
| 223 | + return key, nil | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +func (provider *Provider) refreshKeys(ctx context.Context, now time.Time) error { | ||
| 227 | + request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.config.JWKSURL, nil) | ||
| 228 | + if err != nil { | ||
| 229 | + return ErrProviderResponse | ||
| 230 | + } | ||
| 231 | + response, err := provider.client.Do(request) | ||
| 232 | + if err != nil { | ||
| 233 | + return fmt.Errorf("%w: JWKS fetch failed", ErrProviderResponse) | ||
| 234 | + } | ||
| 235 | + defer response.Body.Close() | ||
| 236 | + if response.StatusCode != http.StatusOK { | ||
| 237 | + return fmt.Errorf("%w: JWKS endpoint status %d", ErrProviderResponse, response.StatusCode) | ||
| 238 | + } | ||
| 239 | + var document struct { | ||
| 240 | + Keys []struct { | ||
| 241 | + KID string `json:"kid"` | ||
| 242 | + KTY string `json:"kty"` | ||
| 243 | + Use string `json:"use"` | ||
| 244 | + Alg string `json:"alg"` | ||
| 245 | + N string `json:"n"` | ||
| 246 | + E string `json:"e"` | ||
| 247 | + } `json:"keys"` | ||
| 248 | + } | ||
| 249 | + if err := json.NewDecoder(io.LimitReader(response.Body, maxProviderResponseBytes)).Decode(&document); err != nil { | ||
| 250 | + return ErrProviderResponse | ||
| 251 | + } | ||
| 252 | + keys := make(map[string]any) | ||
| 253 | + for _, encoded := range document.Keys { | ||
| 254 | + if encoded.KID == "" || encoded.KTY != "RSA" || encoded.Alg != "RS256" || (encoded.Use != "" && encoded.Use != "sig") { | ||
| 255 | + continue | ||
| 256 | + } | ||
| 257 | + key, err := decodeRSAKey(encoded.N, encoded.E) | ||
| 258 | + if err != nil { | ||
| 259 | + continue | ||
| 260 | + } | ||
| 261 | + keys[encoded.KID] = key | ||
| 262 | + } | ||
| 263 | + if len(keys) == 0 { | ||
| 264 | + return ErrProviderResponse | ||
| 265 | + } | ||
| 266 | + provider.keys.keys = keys | ||
| 267 | + provider.keys.expiresAt = now.Add(provider.config.JWKSCacheTTL) | ||
| 268 | + return nil | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +func decodeRSAKey(modulus, exponent string) (*rsa.PublicKey, error) { | ||
| 272 | + nBytes, err := base64.RawURLEncoding.DecodeString(modulus) | ||
| 273 | + if err != nil || len(nBytes) == 0 { | ||
| 274 | + return nil, ErrProviderResponse | ||
| 275 | + } | ||
| 276 | + eBytes, err := base64.RawURLEncoding.DecodeString(exponent) | ||
| 277 | + if err != nil || len(eBytes) == 0 || len(eBytes) > 4 { | ||
| 278 | + return nil, ErrProviderResponse | ||
| 279 | + } | ||
| 280 | + exponentValue := 0 | ||
| 281 | + for _, value := range eBytes { | ||
| 282 | + exponentValue = exponentValue<<8 + int(value) | ||
| 283 | + } | ||
| 284 | + if exponentValue < 3 { | ||
| 285 | + return nil, ErrProviderResponse | ||
| 286 | + } | ||
| 287 | + return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: exponentValue}, nil | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +func validateConfig(config Config) error { | ||
| 291 | + if strings.TrimSpace(config.ClientID) == "" || strings.TrimSpace(config.ClientSecret) == "" { | ||
| 292 | + return errors.New("Telegram OIDC client ID and secret are required") | ||
| 293 | + } | ||
| 294 | + if config.RedirectURI == "" { | ||
| 295 | + return errors.New("Telegram OIDC redirect URI is required") | ||
| 296 | + } | ||
| 297 | + if len(config.AllowedAlgorithms) != 1 || config.AllowedAlgorithms[0] != "RS256" { | ||
| 298 | + return errors.New("Telegram OIDC must be pinned to RS256 for the MVP") | ||
| 299 | + } | ||
| 300 | + for name, raw := range map[string]string{ | ||
| 301 | + "issuer": config.Issuer, "authorization endpoint": config.AuthorizationEndpoint, | ||
| 302 | + "token endpoint": config.TokenEndpoint, "JWKS URL": config.JWKSURL, | ||
| 303 | + } { | ||
| 304 | + parsed, err := url.Parse(raw) | ||
| 305 | + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { | ||
| 306 | + return fmt.Errorf("Telegram OIDC %s is invalid", name) | ||
| 307 | + } | ||
| 308 | + loopback := parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1") | ||
| 309 | + if parsed.Scheme != "https" && !(config.AllowLoopbackProvider && loopback) { | ||
| 310 | + return fmt.Errorf("Telegram OIDC %s must use HTTPS", name) | ||
| 311 | + } | ||
| 312 | + } | ||
| 313 | + if !config.AllowLoopbackProvider && (config.Issuer != DefaultIssuer || | ||
| 314 | + config.AuthorizationEndpoint != DefaultAuthorizationEndpoint || | ||
| 315 | + config.TokenEndpoint != DefaultTokenEndpoint || config.JWKSURL != DefaultJWKSURL) { | ||
| 316 | + return errors.New("non-local Telegram OIDC must use the documented Telegram issuer and endpoints") | ||
| 317 | + } | ||
| 318 | + if _, err := strconv.ParseUint(config.ClientID, 10, 64); err != nil && !config.AllowLoopbackProvider { | ||
| 319 | + return errors.New("Telegram OIDC client ID must be the numeric BotFather client ID") | ||
| 320 | + } | ||
| 321 | + return nil | ||
| 322 | +} | ||
| 323 | + | ||
| 324 | +var _ ports.OIDCProvider = (*Provider)(nil) | ||
| @@ -0,0 +1,224 @@ | |||
| 1 | +package telegramoidc_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "crypto/sha256" | ||
| 6 | + "encoding/base64" | ||
| 7 | + "errors" | ||
| 8 | + "net/http" | ||
| 9 | + "net/http/httptest" | ||
| 10 | + "net/url" | ||
| 11 | + "strings" | ||
| 12 | + "testing" | ||
| 13 | + "time" | ||
| 14 | + | ||
| 15 | + "gitcode.com/urandon/sessionless/internal/domain" | ||
| 16 | + "gitcode.com/urandon/sessionless/internal/oidcfixture" | ||
| 17 | + "gitcode.com/urandon/sessionless/internal/ports" | ||
| 18 | + "gitcode.com/urandon/sessionless/internal/telegramoidc" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +func TestLocalFixtureAndProviderAuthorizationCodeFlow(t *testing.T) { | ||
| 22 | + now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) | ||
| 23 | + issuer := "https://127.0.0.1" | ||
| 24 | + redirectURI := "https://web.localhost/auth/telegram/callback" | ||
| 25 | + fixture, err := oidcfixture.New(oidcfixture.Config{ | ||
| 26 | + Environment: "local", Issuer: issuer, | ||
| 27 | + ClientID: "100000", ClientSecret: "fixture-secret", | ||
| 28 | + RedirectURI: redirectURI, Subject: "424242", Now: func() time.Time { return now }, | ||
| 29 | + }) | ||
| 30 | + if err != nil { | ||
| 31 | + t.Fatal(err) | ||
| 32 | + } | ||
| 33 | + providerClient := &http.Client{Transport: handlerTransport{handler: fixture}} | ||
| 34 | + provider, err := telegramoidc.New(telegramoidc.Config{ | ||
| 35 | + Issuer: issuer, AuthorizationEndpoint: issuer + "/auth", | ||
| 36 | + TokenEndpoint: issuer + "/token", JWKSURL: issuer + "/.well-known/jwks.json", | ||
| 37 | + ClientID: "100000", ClientSecret: "fixture-secret", RedirectURI: redirectURI, | ||
| 38 | + AllowedAlgorithms: []string{"RS256"}, AllowLoopbackProvider: true, | ||
| 39 | + HTTPClient: providerClient, | ||
| 40 | + }) | ||
| 41 | + if err != nil { | ||
| 42 | + t.Fatal(err) | ||
| 43 | + } | ||
| 44 | + verifier := strings.Repeat("a", 43) | ||
| 45 | + challenge := "Z6g5qV9jYgX0j2NqW8mQnY5QglW3IVbvBz7GNWJ6E1c" | ||
| 46 | + authorizationURL, err := provider.AuthorizationURL(context.Background(), ports.OIDCAuthorizationRequest{ | ||
| 47 | + Provider: domain.IdentityProviderTelegram, RedirectURI: redirectURI, | ||
| 48 | + State: "state-value", Nonce: "nonce-value", CodeChallenge: challenge, | ||
| 49 | + Scopes: []string{"openid", "profile"}, | ||
| 50 | + }) | ||
| 51 | + if err != nil { | ||
| 52 | + t.Fatal(err) | ||
| 53 | + } | ||
| 54 | + // Replace the manually supplied challenge with the verifier's actual S256 | ||
| 55 | + // value so the local fixture proves PKCE instead of merely returning a JWT. | ||
| 56 | + parsed, _ := url.Parse(authorizationURL) | ||
| 57 | + query := parsed.Query() | ||
| 58 | + query.Set("code_challenge", pkceChallenge(verifier)) | ||
| 59 | + parsed.RawQuery = query.Encode() | ||
| 60 | + authorizationRequest := httptest.NewRequest(http.MethodGet, parsed.String(), nil) | ||
| 61 | + authorizationResponse := httptest.NewRecorder() | ||
| 62 | + fixture.ServeHTTP(authorizationResponse, authorizationRequest) | ||
| 63 | + if authorizationResponse.Code != http.StatusSeeOther { | ||
| 64 | + t.Fatalf("authorization status = %d", authorizationResponse.Code) | ||
| 65 | + } | ||
| 66 | + callback, _ := url.Parse(authorizationResponse.Header().Get("Location")) | ||
| 67 | + claims, err := provider.ExchangeAndVerify(context.Background(), ports.OIDCTokenRequest{ | ||
| 68 | + Provider: domain.IdentityProviderTelegram, Code: callback.Query().Get("code"), | ||
| 69 | + RedirectURI: redirectURI, PKCEVerifier: verifier, ExpectedNonce: "nonce-value", | ||
| 70 | + Policy: domain.OIDCVerificationPolicy{ | ||
| 71 | + Issuer: issuer, Audience: "100000", AllowedAlgorithms: []string{"RS256"}, | ||
| 72 | + }, | ||
| 73 | + Now: now, | ||
| 74 | + }) | ||
| 75 | + if err != nil { | ||
| 76 | + t.Fatal(err) | ||
| 77 | + } | ||
| 78 | + if claims.Subject != "424242" || claims.Nonce != "nonce-value" { | ||
| 79 | + t.Fatalf("claims = %+v", claims) | ||
| 80 | + } | ||
| 81 | + _, err = provider.ExchangeAndVerify(context.Background(), ports.OIDCTokenRequest{ | ||
| 82 | + Provider: domain.IdentityProviderTelegram, Code: callback.Query().Get("code"), | ||
| 83 | + RedirectURI: redirectURI, PKCEVerifier: verifier, ExpectedNonce: "nonce-value", | ||
| 84 | + Policy: domain.OIDCVerificationPolicy{Issuer: issuer, Audience: "100000", AllowedAlgorithms: []string{"RS256"}}, | ||
| 85 | + Now: now, | ||
| 86 | + }) | ||
| 87 | + if !errors.Is(err, telegramoidc.ErrProviderResponse) { | ||
| 88 | + t.Fatalf("authorization-code replay error = %v", err) | ||
| 89 | + } | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +func TestFixtureRefusesCloudModes(t *testing.T) { | ||
| 93 | + _, err := oidcfixture.New(oidcfixture.Config{Environment: "cloud-dev"}) | ||
| 94 | + if err == nil { | ||
| 95 | + t.Fatal("cloud-dev fixture unexpectedly started") | ||
| 96 | + } | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +func TestProviderRefusesEndpointOverridesOutsideLocalEnvironment(t *testing.T) { | ||
| 100 | + _, err := telegramoidc.New(telegramoidc.Config{ | ||
| 101 | + Issuer: "https://provider.invalid", AuthorizationEndpoint: "https://provider.invalid/auth", | ||
| 102 | + TokenEndpoint: "https://provider.invalid/token", JWKSURL: "https://provider.invalid/jwks", | ||
| 103 | + ClientID: "100000", ClientSecret: "secret", | ||
| 104 | + RedirectURI: "https://web.example/auth/telegram/callback", AllowedAlgorithms: []string{"RS256"}, | ||
| 105 | + }) | ||
| 106 | + if err == nil { | ||
| 107 | + t.Fatal("non-local provider override unexpectedly passed validation") | ||
| 108 | + } | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +func TestProviderRejectsWrongPKCEAndUntrustedSigningKey(t *testing.T) { | ||
| 112 | + now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) | ||
| 113 | + issuer := "https://oidc.localhost" | ||
| 114 | + redirectURI := "https://web.localhost/auth/telegram/callback" | ||
| 115 | + verifier := strings.Repeat("v", 43) | ||
| 116 | + | ||
| 117 | + newFixture := func(t *testing.T) *oidcfixture.Server { | ||
| 118 | + t.Helper() | ||
| 119 | + fixture, err := oidcfixture.New(oidcfixture.Config{ | ||
| 120 | + Environment: "local", Issuer: issuer, ClientID: "100000", | ||
| 121 | + ClientSecret: "fixture-secret", RedirectURI: redirectURI, | ||
| 122 | + Subject: "424242", Now: func() time.Time { return now }, | ||
| 123 | + }) | ||
| 124 | + if err != nil { | ||
| 125 | + t.Fatal(err) | ||
| 126 | + } | ||
| 127 | + return fixture | ||
| 128 | + } | ||
| 129 | + newProvider := func(t *testing.T, transport http.RoundTripper) *telegramoidc.Provider { | ||
| 130 | + t.Helper() | ||
| 131 | + provider, err := telegramoidc.New(telegramoidc.Config{ | ||
| 132 | + Issuer: issuer, AuthorizationEndpoint: issuer + "/auth", | ||
| 133 | + TokenEndpoint: issuer + "/token", JWKSURL: issuer + "/.well-known/jwks.json", | ||
| 134 | + ClientID: "100000", ClientSecret: "fixture-secret", RedirectURI: redirectURI, | ||
| 135 | + AllowedAlgorithms: []string{"RS256"}, AllowLoopbackProvider: true, | ||
| 136 | + HTTPClient: &http.Client{Transport: transport}, | ||
| 137 | + }) | ||
| 138 | + if err != nil { | ||
| 139 | + t.Fatal(err) | ||
| 140 | + } | ||
| 141 | + return provider | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + t.Run("wrong PKCE verifier", func(t *testing.T) { | ||
| 145 | + fixture := newFixture(t) | ||
| 146 | + provider := newProvider(t, handlerTransport{handler: fixture}) | ||
| 147 | + code := issueCode(t, fixture, issuer, redirectURI, verifier) | ||
| 148 | + _, err := provider.ExchangeAndVerify(context.Background(), tokenRequest(code, strings.Repeat("x", 43), issuer, redirectURI, now)) | ||
| 149 | + if !errors.Is(err, telegramoidc.ErrProviderResponse) { | ||
| 150 | + t.Fatalf("wrong PKCE error = %v", err) | ||
| 151 | + } | ||
| 152 | + }) | ||
| 153 | + | ||
| 154 | + t.Run("JWKS does not trust the signing key", func(t *testing.T) { | ||
| 155 | + fixture := newFixture(t) | ||
| 156 | + provider := newProvider(t, filteringTransport{handler: fixture, emptyJWKS: true}) | ||
| 157 | + code := issueCode(t, fixture, issuer, redirectURI, verifier) | ||
| 158 | + _, err := provider.ExchangeAndVerify(context.Background(), tokenRequest(code, verifier, issuer, redirectURI, now)) | ||
| 159 | + if !errors.Is(err, telegramoidc.ErrProviderResponse) { | ||
| 160 | + t.Fatalf("untrusted signing key error = %v", err) | ||
| 161 | + } | ||
| 162 | + }) | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +func issueCode(t *testing.T, fixture http.Handler, issuer, redirectURI, verifier string) string { | ||
| 166 | + t.Helper() | ||
| 167 | + authorization, _ := url.Parse(issuer + "/auth") | ||
| 168 | + query := authorization.Query() | ||
| 169 | + query.Set("client_id", "100000") | ||
| 170 | + query.Set("redirect_uri", redirectURI) | ||
| 171 | + query.Set("response_type", "code") | ||
| 172 | + query.Set("scope", "openid profile") | ||
| 173 | + query.Set("state", "state-value") | ||
| 174 | + query.Set("nonce", "nonce-value") | ||
| 175 | + query.Set("code_challenge", pkceChallenge(verifier)) | ||
| 176 | + query.Set("code_challenge_method", "S256") | ||
| 177 | + authorization.RawQuery = query.Encode() | ||
| 178 | + response := httptest.NewRecorder() | ||
| 179 | + fixture.ServeHTTP(response, httptest.NewRequest(http.MethodGet, authorization.String(), nil)) | ||
| 180 | + if response.Code != http.StatusSeeOther { | ||
| 181 | + t.Fatalf("authorization status = %d body=%s", response.Code, response.Body.String()) | ||
| 182 | + } | ||
| 183 | + callback, _ := url.Parse(response.Header().Get("Location")) | ||
| 184 | + return callback.Query().Get("code") | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +func tokenRequest(code, verifier, issuer, redirectURI string, now time.Time) ports.OIDCTokenRequest { | ||
| 188 | + return ports.OIDCTokenRequest{ | ||
| 189 | + Provider: domain.IdentityProviderTelegram, Code: code, RedirectURI: redirectURI, | ||
| 190 | + PKCEVerifier: verifier, ExpectedNonce: "nonce-value", Now: now, | ||
| 191 | + Policy: domain.OIDCVerificationPolicy{ | ||
| 192 | + Issuer: issuer, Audience: "100000", AllowedAlgorithms: []string{"RS256"}, | ||
| 193 | + }, | ||
| 194 | + } | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +func pkceChallenge(verifier string) string { | ||
| 198 | + digest := sha256.Sum256([]byte(verifier)) | ||
| 199 | + return base64.RawURLEncoding.EncodeToString(digest[:]) | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +type handlerTransport struct{ handler http.Handler } | ||
| 203 | + | ||
| 204 | +func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { | ||
| 205 | + recorder := httptest.NewRecorder() | ||
| 206 | + transport.handler.ServeHTTP(recorder, request) | ||
| 207 | + return recorder.Result(), nil | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +type filteringTransport struct { | ||
| 211 | + handler http.Handler | ||
| 212 | + emptyJWKS bool | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +func (transport filteringTransport) RoundTrip(request *http.Request) (*http.Response, error) { | ||
| 216 | + if transport.emptyJWKS && request.URL.Path == "/.well-known/jwks.json" { | ||
| 217 | + response := httptest.NewRecorder() | ||
| 218 | + response.Header().Set("Content-Type", "application/json") | ||
| 219 | + response.WriteHeader(http.StatusOK) | ||
| 220 | + _, _ = response.WriteString(`{"keys":[]}`) | ||
| 221 | + return response.Result(), nil | ||
| 222 | + } | ||
| 223 | + return handlerTransport{handler: transport.handler}.RoundTrip(request) | ||
| 224 | +} | ||
| @@ -46,8 +46,8 @@ func TestEmbeddedMigrationsAreSingleOperationAndOrdered(t *testing.T) { | |||
| 46 | if err != nil { | 46 | if err != nil { |
| 47 | t.Fatal(err) | 47 | t.Fatal(err) |
| 48 | } | 48 | } |
| 49 | - if len(migrations) != 48 { | 49 | + if len(migrations) == 0 { |
| 50 | - t.Fatalf("embedded migration count = %d, want 48", len(migrations)) | 50 | + t.Fatal("embedded migration set must not be empty") |
| 51 | } | 51 | } |
| 52 | for index, migration := range migrations { | 52 | for index, migration := range migrations { |
| 53 | want := int64(index + 1) | 53 | want := int64(index + 1) |