mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 08:21:50 -06:00
1725bfa50b
* oauth: bind state to browser session Bind each authorization state to a short-lived HttpOnly cookie so callback URLs cannot create sessions in browsers that did not initiate login. Use per-state cookie names to preserve concurrent flows and reject callback origins that cannot receive the host-only cookie. * oauth: preserve explicit proxy redirects Keep browser-state binding usable when TLS terminates before Neko while still rejecting callback hosts that cannot receive the login cookie.
402 lines
11 KiB
Go
402 lines
11 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/m1k1o/neko/server/pkg/types"
|
|
)
|
|
|
|
const StateLifetime = 10 * time.Minute
|
|
|
|
type state struct {
|
|
verifier, redirectURL string
|
|
expires time.Time
|
|
}
|
|
|
|
// Service owns the OAuth authorization-code flow and creates sessions for the
|
|
// OAuth member provider. HTTP packages only translate requests and responses.
|
|
type Service struct {
|
|
config Config
|
|
sessions types.SessionManager
|
|
client *http.Client
|
|
mu sync.Mutex
|
|
issuerResolved bool
|
|
states map[string]state
|
|
extra map[string]map[string]any
|
|
}
|
|
|
|
func NewService(config Config, sessions types.SessionManager) *Service {
|
|
service := &Service{
|
|
config: config,
|
|
sessions: sessions,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
states: make(map[string]state),
|
|
extra: make(map[string]map[string]any),
|
|
}
|
|
|
|
sessions.OnDeleted(func(session types.Session) {
|
|
service.deleteExtraData(session.ID())
|
|
})
|
|
|
|
return service
|
|
}
|
|
|
|
func (service *Service) Enabled() bool { return service.config.Enabled }
|
|
|
|
func (service *Service) Name() string {
|
|
if name := strings.TrimSpace(service.config.Name); name != "" {
|
|
return name
|
|
}
|
|
return "OAuth"
|
|
}
|
|
|
|
func (service *Service) SuccessRedirect() string { return service.config.SuccessRedirect }
|
|
|
|
func (service *Service) Start(ctx context.Context, callbackURL string) (string, error) {
|
|
config, err := service.resolveConfig(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("issuer discovery: %w", err)
|
|
}
|
|
if !config.IsConfigured() {
|
|
return "", errors.New("OAuth is not fully configured")
|
|
}
|
|
|
|
if config.RedirectURL != "" {
|
|
callbackURL = config.RedirectURL
|
|
}
|
|
if callbackURL == "" {
|
|
return "", errors.New("OAuth callback URL is not configured")
|
|
}
|
|
|
|
stateToken, err := token(32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
verifier, err := token(64)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
service.storeState(stateToken, verifier, callbackURL)
|
|
|
|
authorizationURL, err := url.Parse(config.AuthorizationURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := authorizationURL.Query()
|
|
query.Set("response_type", "code")
|
|
query.Set("client_id", config.ClientID)
|
|
query.Set("redirect_uri", callbackURL)
|
|
query.Set("scope", strings.Join(config.Scopes, " "))
|
|
query.Set("state", stateToken)
|
|
query.Set("code_challenge", challenge(verifier))
|
|
query.Set("code_challenge_method", "S256")
|
|
authorizationURL.RawQuery = query.Encode()
|
|
|
|
return authorizationURL.String(), nil
|
|
}
|
|
|
|
func (service *Service) Complete(ctx context.Context, stateToken, code string) (types.Session, string, error) {
|
|
if !service.config.Enabled {
|
|
return nil, "", types.ErrMemberInvalidPassword
|
|
}
|
|
|
|
if stateToken == "" || code == "" {
|
|
return nil, "", errors.New("OAuth callback is missing state or code")
|
|
}
|
|
|
|
stored, ok := service.popState(stateToken)
|
|
if !ok {
|
|
return nil, "", errors.New("OAuth state is invalid or expired")
|
|
}
|
|
|
|
config, err := service.resolveConfig(ctx)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("issuer discovery: %w", err)
|
|
}
|
|
if !config.IsConfigured() {
|
|
return nil, "", errors.New("OAuth is not fully configured")
|
|
}
|
|
|
|
accessToken, idClaims, err := service.exchangeCode(ctx, config, code, stored.verifier, stored.redirectURL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("token exchange: %w", err)
|
|
}
|
|
|
|
claims, err := service.userInfo(ctx, config, accessToken)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("user-info: %w", err)
|
|
}
|
|
|
|
subject := claim(claims, config.SubjectField)
|
|
if subject == "" {
|
|
return nil, "", errors.New("OAuth user-info response is missing the subject field")
|
|
}
|
|
|
|
if idSubject := claim(idClaims, config.SubjectField); idSubject != "" && idSubject != subject {
|
|
return nil, "", errors.New("OAuth ID token subject does not match user-info response")
|
|
}
|
|
|
|
name := claim(claims, config.UsernameField)
|
|
if name == "" {
|
|
name = claim(idClaims, config.UsernameField)
|
|
}
|
|
if name == "" {
|
|
name = subject
|
|
}
|
|
|
|
avatar := claim(claims, config.AvatarField)
|
|
if avatar == "" {
|
|
avatar = claim(idClaims, config.AvatarField)
|
|
}
|
|
|
|
email := claim(claims, "email")
|
|
if email == "" {
|
|
email = claim(idClaims, "email")
|
|
}
|
|
|
|
if email == "" && (len(config.AdminEmails) > 0 || len(config.UserEmails) > 0) {
|
|
log.Warn().Msg("OAuth admin_emails/user_emails are configured but the provider did not return an 'email' claim; request the 'email' scope")
|
|
}
|
|
|
|
isAdmin := boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin")
|
|
admin := administrator(email, isAdmin, config.AdminEmails)
|
|
|
|
if !admin && !emailAllowed(email, config.UserEmails) {
|
|
return nil, "", types.ErrSessionEmailNotAllowed
|
|
}
|
|
|
|
var profile types.MemberProfile
|
|
if admin {
|
|
profile = config.AdminProfile
|
|
} else {
|
|
profile = config.UserProfile
|
|
}
|
|
|
|
profile.Name = name
|
|
profile.Avatar = avatar
|
|
|
|
if !profile.IsAdmin && service.sessions.Settings().LockedLogins {
|
|
return nil, "", types.ErrSessionLoginsLocked
|
|
}
|
|
|
|
id := "oauth:" + subject
|
|
if session, ok := service.sessions.Get(id); ok {
|
|
if session.State().IsConnected {
|
|
return nil, "", types.ErrSessionAlreadyConnected
|
|
}
|
|
if err := service.sessions.Delete(id); err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
|
|
session, sessionToken, err := service.sessions.Create(id, profile)
|
|
if err == nil {
|
|
service.setExtraData(id, claims, idClaims)
|
|
}
|
|
|
|
return session, sessionToken, err
|
|
}
|
|
|
|
func (service *Service) ExtraData(id string) map[string]any {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
return maps.Clone(service.extra[id])
|
|
}
|
|
|
|
type discovery struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
|
}
|
|
|
|
func (service *Service) resolveConfig(ctx context.Context) (Config, error) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
if service.config.IssuerURL == "" || service.issuerResolved {
|
|
return service.config, nil
|
|
}
|
|
|
|
issuer, err := url.Parse(service.config.IssuerURL)
|
|
if err != nil || !absoluteURL(service.config.IssuerURL) {
|
|
return Config{}, errors.New("OAuth issuer URL must be an absolute HTTP(S) URL")
|
|
}
|
|
issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration"
|
|
issuer.RawQuery = ""
|
|
issuer.Fragment = ""
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String(), nil)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
response, err := service.client.Do(req)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return Config{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status)
|
|
}
|
|
|
|
var metadata discovery
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if metadata.Issuer != service.config.IssuerURL {
|
|
return Config{}, errors.New("issuer discovery response issuer does not match configured issuer")
|
|
}
|
|
|
|
for field, value := range map[string]string{
|
|
"authorization_endpoint": metadata.AuthorizationEndpoint,
|
|
"token_endpoint": metadata.TokenEndpoint,
|
|
"userinfo_endpoint": metadata.UserInfoEndpoint,
|
|
} {
|
|
if !absoluteURL(value) {
|
|
return Config{}, fmt.Errorf("issuer discovery response has invalid %s", field)
|
|
}
|
|
}
|
|
|
|
service.config.AuthorizationURL = metadata.AuthorizationEndpoint
|
|
service.config.TokenURL = metadata.TokenEndpoint
|
|
service.config.UserInfoURL = metadata.UserInfoEndpoint
|
|
service.issuerResolved = true
|
|
|
|
return service.config, nil
|
|
}
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
|
|
func (service *Service) exchangeCode(ctx context.Context, config Config, code, verifier, redirectURL string) (string, map[string]any, error) {
|
|
values := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {code},
|
|
"redirect_uri": {redirectURL},
|
|
"client_id": {config.ClientID},
|
|
"client_secret": {config.ClientSecret},
|
|
"code_verifier": {verifier},
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(values.Encode()))
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
response, err := service.client.Do(req)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return "", nil, fmt.Errorf("token endpoint returned %s", response.Status)
|
|
}
|
|
|
|
var payload tokenResponse
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&payload); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if payload.AccessToken == "" {
|
|
return "", nil, errors.New("token response has no access_token")
|
|
}
|
|
|
|
return payload.AccessToken, idTokenClaims(payload.IDToken), nil
|
|
}
|
|
|
|
func (service *Service) userInfo(ctx context.Context, config Config, accessToken string) (map[string]any, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.UserInfoURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
response, err := service.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("user-info endpoint returned %s", response.Status)
|
|
}
|
|
|
|
claims := map[string]any{}
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&claims); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
func (service *Service) storeState(value, verifier, redirectURL string) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for key, value := range service.states {
|
|
if now.After(value.expires) {
|
|
delete(service.states, key)
|
|
}
|
|
}
|
|
|
|
service.states[value] = state{verifier, redirectURL, now.Add(StateLifetime)}
|
|
}
|
|
|
|
func (service *Service) popState(value string) (state, bool) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
result, ok := service.states[value]
|
|
delete(service.states, value)
|
|
|
|
return result, ok && time.Now().Before(result.expires)
|
|
}
|
|
|
|
func (service *Service) setExtraData(id string, data ...map[string]any) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
totalLen := 0
|
|
for _, d := range data {
|
|
totalLen += len(d)
|
|
}
|
|
|
|
result := make(map[string]any, totalLen)
|
|
for _, d := range data {
|
|
maps.Copy(result, d)
|
|
}
|
|
service.extra[id] = result
|
|
}
|
|
|
|
func (service *Service) deleteExtraData(id string) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
delete(service.extra, id)
|
|
}
|