Files
amcs/internal/app/oauth.go
Hein 56c84df342 feat(auth): implement OAuth 2.0 authorization code flow and dynamic client registration
- Add OAuth 2.0 support with authorization code flow and dynamic client registration.
- Introduce new handlers for OAuth metadata, client registration, authorization, and token issuance.
- Enhance authentication middleware to support OAuth client credentials.
- Create in-memory stores for authorization codes and tokens.
- Update configuration to include OAuth client details.
- Ensure validation checks for OAuth clients in the configuration.
2026-03-26 21:17:55 +02:00

391 lines
13 KiB
Go

package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.warky.dev/wdevs/amcs/internal/auth"
)
// --- JSON types ---
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type tokenErrorResponse struct {
Error string `json:"error"`
}
type oauthServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}
type registerRequest struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}
type registerResponse struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}
// --- Handlers ---
// oauthMetadataHandler serves GET /.well-known/oauth-authorization-server
// per RFC 8414 for OAuth 2.0 server metadata discovery.
func oauthMetadataHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
base := serverBaseURL(r)
meta := oauthServerMetadata{
Issuer: base,
AuthorizationEndpoint: base + "/oauth/authorize",
TokenEndpoint: base + "/oauth/token",
RegistrationEndpoint: base + "/oauth/register",
ScopesSupported: []string{"mcp"},
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
CodeChallengeMethodsSupported: []string{"S256"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meta)
}
}
// oauthRegisterHandler serves POST /oauth/register per RFC 7591
// (OAuth 2.0 Dynamic Client Registration).
func oauthRegisterHandler(dynClients *auth.DynamicClientStore, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.RedirectURIs) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_client_metadata","error_description":"redirect_uris is required"}`))
return
}
client, err := dynClients.Register(req.ClientName, req.RedirectURIs)
if err != nil {
log.Error("oauth register: failed", slog.String("error", err.Error()))
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
log.Info("oauth register: new client",
slog.String("client_id", client.ClientID),
slog.String("client_name", client.ClientName),
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(registerResponse{
ClientID: client.ClientID,
ClientName: client.ClientName,
RedirectURIs: client.RedirectURIs,
GrantTypes: []string{"authorization_code"},
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: "none",
})
}
}
// oauthAuthorizeHandler serves GET and POST /oauth/authorize.
// GET shows an approval page; POST processes the user's approve/deny action.
func oauthAuthorizeHandler(dynClients *auth.DynamicClientStore, authCodes *auth.AuthCodeStore, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleAuthorizeGET(w, r, dynClients)
case http.MethodPost:
handleAuthorizePOST(w, r, dynClients, authCodes, log)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
}
func handleAuthorizeGET(w http.ResponseWriter, r *http.Request, dynClients *auth.DynamicClientStore) {
q := r.URL.Query()
clientID := q.Get("client_id")
redirectURI := q.Get("redirect_uri")
responseType := q.Get("response_type")
state := q.Get("state")
codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method")
scope := q.Get("scope")
// Validate client and redirect_uri before any redirect (prevents open redirect).
client, ok := dynClients.Lookup(clientID)
if !ok {
http.Error(w, "unknown client_id", http.StatusBadRequest)
return
}
if !client.HasRedirectURI(redirectURI) {
http.Error(w, "redirect_uri not registered for this client", http.StatusBadRequest)
return
}
// Errors from here can safely redirect back to the client.
if responseType != "code" {
oauthRedirectError(w, r, redirectURI, "unsupported_response_type", state)
return
}
if codeChallenge == "" || codeChallengeMethod != "S256" {
oauthRedirectError(w, r, redirectURI, "invalid_request", state)
return
}
serveAuthorizePage(w, client.ClientName, clientID, redirectURI, state, codeChallenge, codeChallengeMethod, scope)
}
func handleAuthorizePOST(w http.ResponseWriter, r *http.Request, dynClients *auth.DynamicClientStore, authCodes *auth.AuthCodeStore, log *slog.Logger) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
clientID := r.FormValue("client_id")
redirectURI := r.FormValue("redirect_uri")
state := r.FormValue("state")
codeChallenge := r.FormValue("code_challenge")
codeChallengeMethod := r.FormValue("code_challenge_method")
scope := r.FormValue("scope")
action := r.FormValue("action")
client, ok := dynClients.Lookup(clientID)
if !ok {
http.Error(w, "unknown client_id", http.StatusBadRequest)
return
}
if !client.HasRedirectURI(redirectURI) {
http.Error(w, "redirect_uri not registered for this client", http.StatusBadRequest)
return
}
if action == "deny" {
oauthRedirectError(w, r, redirectURI, "access_denied", state)
return
}
code, err := authCodes.Issue(auth.AuthCode{
ClientID: clientID,
RedirectURI: redirectURI,
Scope: scope,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
KeyID: clientID,
})
if err != nil {
log.Error("oauth authorize: failed to issue code", slog.String("error", err.Error()))
oauthRedirectError(w, r, redirectURI, "server_error", state)
return
}
target := redirectURI + "?code=" + url.QueryEscape(code)
if state != "" {
target += "&state=" + url.QueryEscape(state)
}
http.Redirect(w, r, target, http.StatusFound)
}
// oauthTokenHandler serves POST /oauth/token.
// Supports grant_type=client_credentials and grant_type=authorization_code.
func oauthTokenHandler(oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
writeTokenError(w, "invalid_request", http.StatusBadRequest)
return
}
switch r.FormValue("grant_type") {
case "client_credentials":
handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
case "authorization_code":
handleAuthorizationCode(w, r, authCodes, tokenStore, log)
default:
writeTokenError(w, "unsupported_grant_type", http.StatusBadRequest)
}
}
}
func handleClientCredentials(w http.ResponseWriter, r *http.Request, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, log *slog.Logger) {
clientID, clientSecret := extractOAuthBasicOrBody(r)
if clientID == "" || clientSecret == "" {
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
writeTokenError(w, "invalid_client", http.StatusUnauthorized)
return
}
keyID, ok := oauthRegistry.Lookup(clientID, clientSecret)
if !ok {
log.Warn("oauth token: invalid client credentials", slog.String("remote_addr", r.RemoteAddr))
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
writeTokenError(w, "invalid_client", http.StatusUnauthorized)
return
}
issueToken(w, keyID, tokenStore, log)
}
func handleAuthorizationCode(w http.ResponseWriter, r *http.Request, authCodes *auth.AuthCodeStore, tokenStore *auth.TokenStore, log *slog.Logger) {
code := r.FormValue("code")
redirectURI := r.FormValue("redirect_uri")
clientID := r.FormValue("client_id")
codeVerifier := r.FormValue("code_verifier")
if code == "" || redirectURI == "" || codeVerifier == "" {
writeTokenError(w, "invalid_request", http.StatusBadRequest)
return
}
entry, ok := authCodes.Consume(code)
if !ok {
writeTokenError(w, "invalid_grant", http.StatusBadRequest)
return
}
if entry.ClientID != clientID || entry.RedirectURI != redirectURI {
writeTokenError(w, "invalid_grant", http.StatusBadRequest)
return
}
if !verifyPKCE(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod) {
log.Warn("oauth token: PKCE verification failed", slog.String("remote_addr", r.RemoteAddr))
writeTokenError(w, "invalid_grant", http.StatusBadRequest)
return
}
issueToken(w, entry.KeyID, tokenStore, log)
}
func issueToken(w http.ResponseWriter, keyID string, tokenStore *auth.TokenStore, log *slog.Logger) {
token, ttl, err := tokenStore.Issue(keyID)
if err != nil {
log.Error("oauth token: failed to issue token", slog.String("error", err.Error()))
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
_ = json.NewEncoder(w).Encode(tokenResponse{
AccessToken: token,
TokenType: "bearer",
ExpiresIn: int(ttl / time.Second),
})
}
// --- Helpers ---
func serveAuthorizePage(w http.ResponseWriter, clientName, clientID, redirectURI, state, codeChallenge, codeChallengeMethod, scope string) {
e := html.EscapeString
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<title>Authorize — AMCS</title>
<style>
body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 1rem}
button{padding:.5rem 1.2rem;margin-right:.5rem;cursor:pointer;font-size:1rem}
</style>
</head>
<body>
<h2>Authorize Access</h2>
<p><strong>%s</strong> is requesting access to this AMCS server.</p>
<form method=POST action=/oauth/authorize>
<input type=hidden name=client_id value="%s">
<input type=hidden name=redirect_uri value="%s">
<input type=hidden name=state value="%s">
<input type=hidden name=code_challenge value="%s">
<input type=hidden name=code_challenge_method value="%s">
<input type=hidden name=scope value="%s">
<button type=submit name=action value=approve>Approve</button>
<button type=submit name=action value=deny>Deny</button>
</form>
</body>
</html>`,
e(clientName), e(clientID), e(redirectURI), e(state),
e(codeChallenge), e(codeChallengeMethod), e(scope))
}
func oauthRedirectError(w http.ResponseWriter, r *http.Request, redirectURI, errCode, state string) {
target := redirectURI + "?error=" + url.QueryEscape(errCode)
if state != "" {
target += "&state=" + url.QueryEscape(state)
}
http.Redirect(w, r, target, http.StatusFound)
}
func verifyPKCE(verifier, challenge, method string) bool {
if method != "S256" {
return false
}
h := sha256.Sum256([]byte(verifier))
got := base64.RawURLEncoding.EncodeToString(h[:])
return subtle.ConstantTimeCompare([]byte(got), []byte(challenge)) == 1
}
func serverBaseURL(r *http.Request) string {
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = strings.ToLower(proto)
} else if r.TLS == nil {
scheme = "http"
}
return scheme + "://" + r.Host
}
func extractOAuthBasicOrBody(r *http.Request) (string, string) {
if id, secret, ok := r.BasicAuth(); ok {
return id, secret
}
return strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
}
func writeTokenError(w http.ResponseWriter, errCode string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(tokenErrorResponse{Error: errCode})
}