mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-04 12:31:58 +00:00
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:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user