feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client

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.
This commit is contained in:
Hein
2026-10-01 14:42:12 +02:00
parent f54b707040
commit 640faeeeaf
51 changed files with 8682 additions and 1125 deletions
+144
View File
@@ -0,0 +1,144 @@
package security
import (
"bytes"
"html/template"
"net/http"
)
// OAuthLoginPage is the data of the login page template.
type OAuthLoginPage struct {
Title string
Error string
Action string // form action
State string // opaque, must be posted back as the "req" field
ClientName string
LoginHint string
// Extra hidden fields to post back (device flow).
Hidden map[string]string
}
// OAuthScopeInfo is one line of the consent screen.
type OAuthScopeInfo struct {
Name string
Description string
}
// OAuthConsentPage is the data of the consent page template.
type OAuthConsentPage struct {
Title string
Action string
State string // opaque, must be posted back as the "req" field
ClientName string
ClientURI string
LogoURI string
Scopes []OAuthScopeInfo
User string
Hidden map[string]string
}
type oauthMessagePage struct {
Title string
Message string
Error bool
}
type oauthDevicePage struct {
Title string
Action string
Error string
UserCode string
}
type oauthLogoutPage struct {
Title string
Action string
Hidden map[string]string
}
type oauthTemplates struct {
login, consent *template.Template
base *template.Template
}
const oauthPageCSS = `body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;padding:2rem;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);width:340px;max-width:92vw}
h2{margin:0 0 1.25rem;font-size:1.25rem}p{color:#444;font-size:.9rem}
label{display:block;margin-bottom:.25rem;font-size:.875rem;color:#555}
input[type=text],input[type=password]{width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #ccc;border-radius:4px;margin-bottom:1rem;font-size:1rem}
button{padding:.6rem 1rem;background:#0070f3;color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer}
button.secondary{background:#e5e5e5;color:#222}button:hover{opacity:.9}.full{width:100%}
.err{color:#d32f2f;margin-bottom:1rem;font-size:.875rem}ul{padding-left:1.2rem}li{margin:.35rem 0;font-size:.9rem}
.row{display:flex;gap:.5rem}.row button{flex:1}.logo{max-height:48px;margin-bottom:1rem}.muted{color:#777;font-size:.8rem}`
const oauthPageTemplates = `
{{define "head"}}<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + oauthPageCSS + `</style></head><body><div class="card">{{end}}
{{define "foot"}}</div></body></html>{{end}}
{{define "hidden"}}{{range $k, $v := .Hidden}}<input type="hidden" name="{{$k}}" value="{{$v}}">{{end}}{{end}}
{{define "login"}}{{template "head" .}}<h2>{{.Title}}</h2>{{if .ClientName}}<p>to continue to <b>{{.ClientName}}</b></p>{{end}}
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<form method="POST" action="{{.Action}}">
<input type="hidden" name="req" value="{{.State}}">{{template "hidden" .}}
<label>Username</label><input type="text" name="username" value="{{.LoginHint}}" autofocus autocomplete="username">
<label>Password</label><input type="password" name="password" autocomplete="current-password">
<button class="full" type="submit">Sign in</button>
</form>{{template "foot"}}{{end}}
{{define "consent"}}{{template "head" .}}{{if .LogoURI}}<img class="logo" src="{{.LogoURI}}" alt="">{{end}}
<h2>{{if .ClientURI}}<a href="{{.ClientURI}}" rel="noopener noreferrer">{{.ClientName}}</a>{{else}}{{.ClientName}}{{end}} wants access</h2>
<p>Signed in as <b>{{.User}}</b>. This application will be able to:</p>
<ul>{{range .Scopes}}<li><b>{{.Name}}</b>{{if .Description}} – {{.Description}}{{end}}</li>{{end}}</ul>
<form method="POST" action="{{.Action}}">
<input type="hidden" name="req" value="{{.State}}">{{template "hidden" .}}
<p class="muted"><label><input type="checkbox" name="remember" value="1" checked> Remember this decision</label></p>
<div class="row"><button class="secondary" type="submit" name="decision" value="deny">Deny</button>
<button type="submit" name="decision" value="allow">Allow</button></div>
</form>{{template "foot"}}{{end}}
{{define "device"}}{{template "head" .}}<h2>{{.Title}}</h2><p>Enter the code shown on your device.</p>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<form method="POST" action="{{.Action}}"><input type="hidden" name="step" value="code">
<input type="text" name="user_code" value="{{.UserCode}}" autofocus autocomplete="off" placeholder="XXXX-XXXX">
<button class="full" type="submit">Continue</button></form>{{template "foot"}}{{end}}
{{define "message"}}{{template "head" .}}<h2>{{.Title}}</h2>{{if .Error}}<div class="err">{{.Message}}</div>{{else}}<p>{{.Message}}</p>{{end}}{{template "foot"}}{{end}}
{{define "logout"}}{{template "head" .}}<h2>{{.Title}}</h2><p>Do you want to sign out?</p>
<form method="POST" action="{{.Action}}">{{template "hidden" .}}
<div class="row"><button class="secondary" type="submit" name="confirm" value="no">Stay signed in</button>
<button type="submit" name="confirm" value="yes">Sign out</button></div></form>{{template "foot"}}{{end}}
`
func newOAuthTemplates(cfg *OAuthServerConfig) oauthTemplates {
base := template.Must(template.New("oauth").Parse(oauthPageTemplates))
return oauthTemplates{base: base, login: cfg.LoginTemplate, consent: cfg.ConsentTemplate}
}
func (s *OAuthServer) renderHTML(w http.ResponseWriter, status int, override *template.Template, name string, data any) {
var buf bytes.Buffer
var err error
if override != nil {
err = override.Execute(&buf, data)
} else {
err = s.tmpl.base.ExecuteTemplate(&buf, name, data)
}
if err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store")
h.Set("Pragma", "no-cache")
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; frame-ancestors 'none'; base-uri 'none'")
w.WriteHeader(status)
w.Write(buf.Bytes()) //nolint:errcheck,gosec // G104: best-effort write, G705: html/template output is escaped
}
func (s *OAuthServer) renderMessage(w http.ResponseWriter, status int, title, msg string, isErr bool) {
s.renderHTML(w, status, nil, "message", oauthMessagePage{Title: title, Message: msg, Error: isErr})
}