mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid, at_hash, signed userinfo, RP-initiated and back-channel logout), managed refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens, DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592 registration, RFC 9207 iss, signing keyring with rotation. State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct backends, four dialect DDLs, conformance cases). Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL. PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse. Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
175 lines
7.1 KiB
Go
175 lines
7.1 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
)
|
|
|
|
// ExampleOAuth2FullServer runs a complete OAuth 2.1 / OpenID Connect provider: login, consent,
|
|
// rotating refresh tokens, JWT access tokens, DPoP, PAR, the device grant and token exchange.
|
|
// OAUTH2_SERVER.md walks through every endpoint.
|
|
func ExampleOAuth2FullServer() {
|
|
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/app?sslmode=disable")
|
|
|
|
// 1. The authenticator holds users and sessions. The OAuth state (clients, codes, consents,
|
|
// refresh tokens, device codes, PAR requests, replay cache) lives in the same database:
|
|
// apply lookup/database_schema.sql (Postgres procedures) or lookup/ddl/<dialect>.sql.
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
|
|
Lookup: lookup.Config{},
|
|
})
|
|
|
|
// 2. Signing keys are persistent so every instance publishes and accepts the same keys.
|
|
// The first key signs; add the next key here first when rotating.
|
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) // load from your secret store instead
|
|
|
|
srv := NewOAuthServer(OAuthServerConfig{ //nolint:gosec // example secrets
|
|
Issuer: "https://auth.example.com",
|
|
SigningKeys: []OAuthSigningKey{{Key: key, Alg: "ES256"}},
|
|
// Share this secret between instances so the SSO cookie works on all of them.
|
|
CookieSecret: []byte("a-32-byte-or-longer-random-secret"),
|
|
|
|
PersistClients: true, // clients survive restarts
|
|
PersistCodes: true, // codes work across instances
|
|
|
|
RequireConsent: true, // ask the user before releasing scopes to a third-party client
|
|
ManagedRefreshTokens: true, // rotate refresh tokens, revoke the family on reuse
|
|
JWTAccessTokens: true, // RFC 9068: resource servers verify locally
|
|
AccessTokenAudience: "https://api.example.com",
|
|
|
|
EnableDPoP: true, // RFC 9449 sender-constrained tokens
|
|
EnablePAR: true, // RFC 9126
|
|
EnableDeviceFlow: true, // RFC 8628 for TVs and CLIs
|
|
EnableTokenExchange: true, // RFC 8693 downscoping for service calls
|
|
|
|
InitialAccessToken: "registration-secret", // only trusted callers may register clients
|
|
ScopeDescriptions: map[string]string{"orders:read": "Read your orders"},
|
|
RateLimiter: func(r *http.Request, endpoint string) bool {
|
|
return true // plug in your limiter; false answers 429
|
|
},
|
|
}, auth)
|
|
// 3. First-party applications skip the consent screen. The secret is shown once.
|
|
app, secret, err := srv.RegisterTrustedClient(context.Background(), OAuthServerClient{
|
|
ClientName: "Admin console",
|
|
RedirectURIs: []string{"https://console.example.com/callback"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
AllowedScopes: []string{"openid", "profile", "email", "offline_access"},
|
|
})
|
|
if err != nil {
|
|
srv.Close()
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Println(app.ClientID, secret)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/", srv.HTTPHandler())
|
|
|
|
// 4. A protected API verifies the JWT access token locally (no database call).
|
|
mux.Handle("/api/orders", requireScope(srv, "orders:read", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(accessClaimsKey{}).(*AccessTokenClaims)
|
|
fmt.Fprintf(w, "orders of %s", claims.Subject)
|
|
})))
|
|
|
|
httpSrv := &http.Server{Addr: ":8443", Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
err = httpSrv.ListenAndServe()
|
|
srv.Close()
|
|
log.Fatal(err)
|
|
}
|
|
|
|
type accessClaimsKey struct{}
|
|
|
|
// requireScope is a resource-server middleware around OAuthServer.VerifyAccessToken.
|
|
func requireScope(srv *OAuthServer, scope string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
claims, err := srv.VerifyAccessToken(r.Context(), token, VerifyAccessTokenOptions{
|
|
Audience: "https://api.example.com",
|
|
Scopes: []string{scope},
|
|
})
|
|
if err != nil {
|
|
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), accessClaimsKey{}, claims)))
|
|
})
|
|
}
|
|
|
|
// ExampleOAuth2FullClient is the relying-party side: log users in with any OpenID Connect
|
|
// provider (including the server above). Discovery, PKCE, nonce and id_token validation are
|
|
// automatic.
|
|
func ExampleOAuth2FullClient() {
|
|
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/app?sslmode=disable")
|
|
auth := NewDatabaseAuthenticator(db)
|
|
|
|
if _, err := auth.WithOIDC(context.Background(), OIDCConfig{
|
|
Issuer: "https://auth.example.com",
|
|
ClientID: "my-client-id",
|
|
ClientSecret: "my-client-secret", // empty for a public client
|
|
RedirectURL: "https://app.example.com/auth/callback",
|
|
ProviderName: "company",
|
|
Scopes: []string{"openid", "profile", "email", "offline_access"},
|
|
}); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
|
state, _ := auth.OAuth2GenerateState()
|
|
// Keep state in a cookie so the callback can be tied to this browser.
|
|
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600})
|
|
maxAge := 3600
|
|
url, err := auth.OAuth2GetAuthURLWithOptions("company", state, OAuth2AuthOptions{MaxAge: &maxAge})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, url, http.StatusFound)
|
|
})
|
|
|
|
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie("oauth_state"); err != nil || c.Value != r.URL.Query().Get("state") {
|
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Checks state, PKCE, the RFC 9207 iss parameter, the id_token (signature, iss, aud, exp,
|
|
// nonce, at_hash) and the userinfo subject; then creates the local user and session.
|
|
login, err := auth.OAuth2HandleCallbackRequest(r.Context(), "company", r)
|
|
if err != nil {
|
|
http.Error(w, "login failed", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
idToken, _ := login.Meta["id_token"].(string) // keep it for logout
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: login.Token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
|
http.SetCookie(w, &http.Cookie{Name: "id_token", Value: idToken, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
})
|
|
|
|
mux.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
|
hint := ""
|
|
if c, err := r.Cookie("id_token"); err == nil {
|
|
hint = c.Value
|
|
}
|
|
// Ends the provider session too (RP-initiated logout).
|
|
url, err := auth.OAuth2LogoutURL(r.Context(), "company", hint, "https://app.example.com/", "bye")
|
|
if err != nil {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
http.Redirect(w, r, url, http.StatusFound)
|
|
})
|
|
|
|
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
log.Fatal(srv.ListenAndServe())
|
|
}
|