package oauth import ( "cmp" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/serviceauth" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpjson" ) const requestURIPrefix = "urn:ietf:params:oauth:request_uri:" type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` Scope string `json:"scope"` Sub string `json:"sub,omitempty"` } func (s *Server) verifyClientAssertion(ctx context.Context, form url.Values, now time.Time) (string, string, error) { assertion := form.Get("client_assertion") if assertion == "" { if form.Get("client_assertion_type") != "" { return "", "", fmt.Errorf("%w: client_assertion is required", ErrInvalidClient) } return "", "", nil } if form.Get("client_assertion_type") != ClientAssertionTypeJWTBearer { return "", "", fmt.Errorf("%w: unsupported client assertion type", ErrInvalidClient) } clientID := form.Get("client_id") if clientID == "" { return "", "", fmt.Errorf("%w: client_id is required with client assertions", ErrInvalidClient) } _, keyJKT, err := s.clients.VerifyClientAssertion(ctx, assertion, clientID, s.issuer, now) if err != nil { return "", "", err } return clientID, keyJKT, nil } func (s *Server) handlePAR(w http.ResponseWriter, r *http.Request) { if _, ok := s.verifyAuthorizationDPoP(w, r, ""); !ok { return } form, err := parseOAuthForm(w, r) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "") return } now := time.Now() ctx := r.Context() clientID := form.Get("client_id") redirectURI := form.Get("redirect_uri") responseType := form.Get("response_type") state := form.Get("state") challenge := form.Get("code_challenge") method := form.Get("code_challenge_method") method = cmp.Or(method, "S256") if clientID == "" || redirectURI == "" || responseType == "" || state == "" || challenge == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id, code_challenge, redirect_uri, response_type, and state required") return } if responseType != "code" { writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "") return } if err := validateCodeChallenge(challenge, method); err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } metadata, err := s.clients.Resolve(ctx, clientID) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata", "could not resolve client metadata") return } if !RedirectURIAllowed(metadata, clientID, redirectURI) { writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uri not registered in client metadata") return } scope, err := normalizeScope(form.Get("scope"), metadata.Scope) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_scope", err.Error()) return } assertedClient, assertedKey, err := s.verifyClientAssertion(ctx, form, now) if err != nil { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } authMethod := metadata.TokenEndpointAuthMethod if assertedClient != "" { authMethod = ClientAuthPrivateKeyJWT } if metadata.TokenEndpointAuthMethod == ClientAuthPrivateKeyJWT && assertedClient == "" { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client assertion required") return } requestID, err := randomToken(32) if err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } request := PARRequest{ RequestURI: requestURIPrefix + requestID, ClientID: clientID, ClientAuthMethod: authMethod, ClientKeyJKT: assertedKey, RedirectURI: redirectURI, Scope: scope, State: state, CodeChallenge: challenge, CodeChallengeMethod: method, ResponseType: responseType, CreatedAt: now, ExpiresAt: now.Add(s.config.PARTTL), } if err := s.pars.StorePAR(ctx, request); err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") httpjson.Write(w, http.StatusOK, struct { RequestURI string `json:"request_uri"` ExpiresIn int64 `json:"expires_in"` }{RequestURI: request.RequestURI, ExpiresIn: int64(s.config.PARTTL / time.Second)}) } func (s *Server) handleAuthorizePage(w http.ResponseWriter, r *http.Request) { requestURI := r.URL.Query().Get("request_uri") clientID := r.URL.Query().Get("client_id") if requestURI == "" || clientID == "" { http.Error(w, "request_uri and client_id are required", http.StatusBadRequest) return } handoff, err := json.Marshal(map[string]string{"requestUri": requestURI, "clientId": clientID}) if err != nil { http.Error(w, "server_error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, authorizePage, handoff) } const authorizePage = ` Sign in

Sign in

` func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) { form, err := parseOAuthForm(w, r) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "") return } now := time.Now() ctx := r.Context() identifier := form.Get("identifier") password := form.Get("password") var account Account if identifier != "" || password != "" { if identifier == "" || password == "" { writeOAuthError(w, http.StatusUnauthorized, "invalid_grant", "invalid identifier or password") return } authenticated, err := s.accounts.Authenticate(ctx, identifier, password) if err != nil { writeOAuthError(w, http.StatusUnauthorized, "invalid_grant", "invalid identifier or password") return } account = authenticated } else { scheme, token, ok := serviceauth.SplitAuthorization(r.Header.Get("Authorization")) if !ok || !strings.EqualFold(scheme, AuthSchemeDPoP) { writeOAuthError(w, http.StatusUnauthorized, "invalid_request", "DPoP authorization required") return } session, err := s.ValidateAccessToken(ctx, token) if err != nil { writeOAuthError(w, http.StatusUnauthorized, "invalid_token", "") return } account = session.Account } requestURI := form.Get("request_uri") var request PARRequest if requestURI != "" { clientID := form.Get("client_id") if clientID == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id is required") return } stored, err := s.pars.ConsumePAR(ctx, requestURI, now) if err != nil || stored == nil || !stored.ExpiresAt.After(now) || stored.ClientID != clientID { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid or expired request_uri") return } request = *stored } else { clientID := form.Get("client_id") redirectURI := form.Get("redirect_uri") challenge := form.Get("code_challenge") method := form.Get("code_challenge_method") method = cmp.Or(method, "S256") responseType := form.Get("response_type") if clientID == "" || redirectURI == "" || challenge == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code_challenge and redirect_uri required") return } if responseType != "" && responseType != "code" { writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "") return } if err := validateCodeChallenge(challenge, method); err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } metadata, err := s.clients.Resolve(ctx, clientID) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata", "could not resolve client metadata") return } if !RedirectURIAllowed(metadata, clientID, redirectURI) { writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uri not registered in client metadata") return } scope, err := normalizeScope(form.Get("scope"), metadata.Scope) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_scope", err.Error()) return } responseType = cmp.Or(responseType, "code") request = PARRequest{ ClientID: clientID, ClientAuthMethod: metadata.TokenEndpointAuthMethod, RedirectURI: redirectURI, Scope: scope, State: form.Get("state"), CodeChallenge: challenge, CodeChallengeMethod: method, ResponseType: responseType, CreatedAt: now, ExpiresAt: now.Add(s.config.AuthorizationCodeTTL), } } codeID, err := randomToken(32) if err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } sessionID, err := randomToken(32) if err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } code := AuthorizationCode{ ID: codeID, SessionID: sessionID, ClientID: request.ClientID, ClientAuthMethod: request.ClientAuthMethod, ClientKeyJKT: request.ClientKeyJKT, Account: account, Scope: request.Scope, RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, CodeChallengeMethod: request.CodeChallengeMethod, CreatedAt: now, ExpiresAt: now.Add(s.config.AuthorizationCodeTTL), } if err := s.codes.CreateCode(ctx, code); err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } redirect, err := authorizeRedirect(request.RedirectURI, codeID, request.State, s.issuer) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") httpjson.Write(w, http.StatusOK, struct { RedirectURI string `json:"redirect_uri"` Code string `json:"code"` }{RedirectURI: redirect, Code: codeID}) } func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { proof, ok := s.verifyAuthorizationDPoP(w, r, "") if !ok { return } form, err := parseOAuthForm(w, r) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "") return } now := time.Now() ctx := r.Context() assertedClient, assertedKey, err := s.verifyClientAssertion(ctx, form, now) if err != nil { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client assertion verification failed") return } formClient := form.Get("client_id") if assertedClient != "" { formClient = cmp.Or(formClient, assertedClient) if formClient != assertedClient { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } } switch form.Get("grant_type") { case "authorization_code": s.handleAuthorizationCodeGrant(w, r, form, proof, formClient, assertedClient, assertedKey, now) case "refresh_token": s.handleRefreshGrant(w, r, form, proof, formClient, assertedClient, assertedKey, now) default: writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "") } } func (s *Server) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request, form url.Values, proof *DPoPProof, formClient, assertedClient, assertedKey string, now time.Time) { ctx := r.Context() codeID := form.Get("code") verifier := form.Get("code_verifier") if codeID == "" || verifier == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code and code_verifier required") return } code, err := s.codes.ConsumeCode(ctx, codeID, now) if err != nil { if errors.Is(err, ErrCodeUsed) && code != nil { s.revokeSession(ctx, code.SessionID, now) } writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired authorization code") return } if code == nil || !code.ExpiresAt.After(now) || code.CodeChallengeMethod != "S256" { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired authorization code") return } if !verifyPKCE(verifier, code.CodeChallenge) { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid code_verifier") return } redirectURI := form.Get("redirect_uri") if redirectURI == "" || redirectURI != code.RedirectURI { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri mismatch") return } if formClient != "" && formClient != code.ClientID { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } if code.ClientAuthMethod == ClientAuthPrivateKeyJWT { if assertedClient == "" || assertedClient != code.ClientID { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } } if code.ClientKeyJKT != "" && assertedKey != code.ClientKeyJKT { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } session := Session{ ID: code.SessionID, Account: code.Account, ClientID: code.ClientID, ClientAuthMethod: code.ClientAuthMethod, ClientKeyJKT: code.ClientKeyJKT, Scope: code.Scope, JKT: proof.JKT, CreatedAt: now, ExpiresAt: now.Add(s.config.RefreshTokenTTL), } if err := s.sessions.CreateSession(ctx, session); err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } access, refresh, err := s.mintTokenPair(session, now) if err != nil { s.revokeSession(ctx, session.ID, now) writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } if err := s.storeTokenPair(ctx, session.ID, access, refresh); err != nil { s.revokeSession(ctx, session.ID, now) writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") httpjson.Write(w, http.StatusOK, tokenResponse{ AccessToken: access.Token, TokenType: AuthSchemeDPoP, RefreshToken: refresh.Token, ExpiresIn: int64(s.config.AccessTokenTTL / time.Second), Scope: session.Scope, Sub: session.Account.DID.String(), }) } func (s *Server) handleRefreshGrant(w http.ResponseWriter, r *http.Request, form url.Values, proof *DPoPProof, formClient, assertedClient, assertedKey string, now time.Time) { ctx := r.Context() refreshToken := form.Get("refresh_token") if refreshToken == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "missing refresh_token") return } claims, err := parseOAuthToken(refreshToken, TokenRefresh, s.signer, now) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired refresh token") return } record, err := s.tokens.GetToken(ctx, claims.JTI) if err != nil || record == nil || record.Kind != TokenRefresh || record.RevokedAt != nil || !record.ExpiresAt.After(now) { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired refresh token") return } session, err := s.sessions.GetSession(ctx, record.SessionID) if err != nil || session == nil || session.RevokedAt != nil || !session.ExpiresAt.After(now) { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired refresh token") return } if session.ID != record.SessionID || claims.Sub != session.Account.DID.String() || claims.CNF.JKT != session.JKT || proof.JKT != session.JKT { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "refresh token is not bound to this DPoP key") return } if formClient != "" && formClient != session.ClientID { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } if assertedClient != "" && assertedClient != session.ClientID { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } if session.ClientAuthMethod == ClientAuthPrivateKeyJWT && assertedClient != session.ClientID { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } if session.ClientKeyJKT != "" && assertedKey != session.ClientKeyJKT { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "") return } access, refresh, err := s.mintTokenPair(*session, now) if err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } if err := s.sessions.ExtendSession(ctx, session.ID, now.Add(s.config.RefreshTokenTTL)); err != nil { writeOAuthError(w, http.StatusInternalServerError, "server_error", "") return } records := []TokenRecord{ makeTokenRecord(session.ID, TokenAccess, access), makeTokenRecord(session.ID, TokenRefresh, refresh), } if err := s.tokens.RotateRefreshToken(ctx, claims.JTI, now, records); err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "invalid or expired refresh token") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") httpjson.Write(w, http.StatusOK, tokenResponse{ AccessToken: access.Token, TokenType: AuthSchemeDPoP, RefreshToken: refresh.Token, ExpiresIn: int64(s.config.AccessTokenTTL / time.Second), Scope: session.Scope, }) } func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) { if r.Header.Get(HeaderDPoP) != "" { if _, ok := s.verifyAuthorizationDPoP(w, r, ""); !ok { return } } form, err := parseOAuthForm(w, r) if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "") return } token := form.Get("token") if token == "" { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "token required") return } now := time.Now() ctx := r.Context() claims, kind, ok := parseRevocableToken(token, s.signer, now) if !ok { w.Header().Set("Cache-Control", "no-store") httpjson.Write(w, http.StatusOK, map[string]any{}) return } record, err := s.tokens.GetToken(ctx, claims.JTI) if err == nil && record != nil { if kind == TokenRefresh { s.revokeSession(ctx, record.SessionID, now) } else { _ = s.tokens.RevokeToken(ctx, record.JTI, now) } } w.Header().Set("Cache-Control", "no-store") httpjson.Write(w, http.StatusOK, map[string]any{}) } func parseRevocableToken(token string, signer Signer, now time.Time) (*tokenClaims, TokenKind, bool) { if claims, err := parseOAuthToken(token, TokenAccess, signer, now); err == nil { return claims, TokenAccess, true } if claims, err := parseOAuthToken(token, TokenRefresh, signer, now); err == nil { return claims, TokenRefresh, true } return nil, "", false } func (s *Server) revokeSession(ctx context.Context, sessionID string, at time.Time) { if sessionID == "" { return } _ = s.sessions.RevokeSession(ctx, sessionID, at) _ = s.tokens.RevokeSessionTokens(ctx, sessionID, at) } func (s *Server) mintTokenPair(session Session, now time.Time) (*signedToken, *signedToken, error) { access, err := issueSignedToken(s.signer, now, TokenAccess, session, s.config.AccessTokenTTL) if err != nil { return nil, nil, err } refresh, err := issueSignedToken(s.signer, now, TokenRefresh, session, s.config.RefreshTokenTTL) if err != nil { return nil, nil, err } return access, refresh, nil } func (s *Server) storeTokenPair(ctx context.Context, sessionID string, access, refresh *signedToken) error { if err := s.tokens.CreateToken(ctx, makeTokenRecord(sessionID, TokenAccess, access)); err != nil { return err } if err := s.tokens.CreateToken(ctx, makeTokenRecord(sessionID, TokenRefresh, refresh)); err != nil { _ = s.tokens.RevokeToken(ctx, access.JTI, time.Now()) return err } return nil } func makeTokenRecord(sessionID string, kind TokenKind, token *signedToken) TokenRecord { return TokenRecord{ JTI: token.JTI, SessionID: sessionID, Kind: kind, IssuedAt: token.IssuedAt, ExpiresAt: token.ExpiresAt, } }