package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "amnezia-share/internal/models" ) // OIDCConfig holds the Pocket ID SSO settings (see settings.KeyPocketURL / // KeyPocketClientID / KeyPocketSecret in internal/settings). type OIDCConfig struct { URL string ClientID string ClientSecret string } // Configured reports whether all three OIDC settings are present. func (c OIDCConfig) Configured() bool { return strings.TrimSpace(c.URL) != "" && strings.TrimSpace(c.ClientID) != "" && strings.TrimSpace(c.ClientSecret) != "" } func (c OIDCConfig) baseURL() string { return strings.TrimRight(strings.TrimSpace(c.URL), "/") } // GenerateState returns a random hex CSRF state value for the OIDC authorize // request. func GenerateState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return fmt.Sprintf("%x", b), nil } // GeneratePKCE returns a PKCE code_verifier and its S256 code_challenge // (RFC 7636), both base64url-encoded without padding. func GeneratePKCE() (verifier, challenge string, err error) { raw := make([]byte, 32) if _, err = rand.Read(raw); err != nil { return "", "", err } verifier = base64.RawURLEncoding.EncodeToString(raw) sum := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(sum[:]) return verifier, challenge, nil } // AuthURL builds the Pocket ID /authorize URL for the PKCE S256 authorization-code // flow. func AuthURL(cfg OIDCConfig, redirectURI, state, codeChallenge string) string { q := url.Values{} q.Set("response_type", "code") q.Set("client_id", cfg.ClientID) q.Set("redirect_uri", redirectURI) q.Set("scope", "openid profile email") q.Set("state", state) q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") return cfg.baseURL() + "/authorize?" + q.Encode() } // UserInfo is the subset of the OIDC userinfo response we care about. type UserInfo struct { PreferredUsername string Name string Email string Sub string } // LookupName picks the identifier used to match against the local users table: // preferred_username, then name, then email, then sub. func (u UserInfo) LookupName() string { switch { case u.PreferredUsername != "": return u.PreferredUsername case u.Name != "": return u.Name case u.Email != "": return u.Email default: return u.Sub } } var httpClientOIDC = &http.Client{Timeout: 15 * time.Second} // ExchangeAndUserinfo exchanges an authorization code for an access token and // fetches the user profile. It mirrors the Pocket ID quirks handled by the PHP // implementation: // - token endpoint tried at /api/oidc/token first, then /token, // - for each endpoint, Basic auth (client_id:client_secret) is tried first, then // falls back to client_id/client_secret in the POST body, // - userinfo endpoint tried at /api/oidc/userinfo first, then /userinfo. func ExchangeAndUserinfo(ctx context.Context, cfg OIDCConfig, code, redirectURI, codeVerifier string) (UserInfo, error) { base := cfg.baseURL() tokenEndpoints := []string{base + "/api/oidc/token", base + "/token"} basicAuth := base64.StdEncoding.EncodeToString([]byte(cfg.ClientID + ":" + cfg.ClientSecret)) var accessToken string var lastTokenErr error for _, endpoint := range tokenEndpoints { fields := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, } if codeVerifier != "" { fields.Set("code_verifier", codeVerifier) } if tok, err := postTokenRequest(ctx, endpoint, fields, map[string]string{"Authorization": "Basic " + basicAuth}); err == nil && tok != "" { accessToken = tok break } else if err != nil { lastTokenErr = err } fieldsSecret := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "client_id": {cfg.ClientID}, "client_secret": {cfg.ClientSecret}, } if codeVerifier != "" { fieldsSecret.Set("code_verifier", codeVerifier) } if tok, err := postTokenRequest(ctx, endpoint, fieldsSecret, nil); err == nil && tok != "" { accessToken = tok break } else if err != nil { lastTokenErr = err } } if accessToken == "" { if lastTokenErr != nil { return UserInfo{}, fmt.Errorf("Pocket ID не вернул access_token: %w", lastTokenErr) } return UserInfo{}, errors.New("Pocket ID не вернул access_token.") } userinfoEndpoints := []string{base + "/api/oidc/userinfo", base + "/userinfo"} var raw map[string]any var lastErr error for _, endpoint := range userinfoEndpoints { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { lastErr = err continue } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := httpClientOIDC.Do(req) if err != nil { lastErr = err continue } body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) _ = resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { lastErr = fmt.Errorf("userinfo HTTP %d", resp.StatusCode) continue } if err := json.Unmarshal(body, &raw); err != nil { lastErr = err continue } lastErr = nil break } if raw == nil { if lastErr == nil { lastErr = errors.New("нет ответа") } return UserInfo{}, fmt.Errorf("не удалось получить профиль от Pocket ID: %w", lastErr) } info := UserInfo{ PreferredUsername: strings.TrimSpace(stringAny(raw["preferred_username"])), Name: strings.TrimSpace(stringAny(raw["name"])), Email: strings.TrimSpace(stringAny(raw["email"])), Sub: strings.TrimSpace(stringAny(raw["sub"])), } if info.Sub == "" && info.PreferredUsername == "" && info.Name == "" && info.Email == "" { return UserInfo{}, errors.New("Pocket ID не вернул идентификатор пользователя.") } return info, nil } func stringAny(v any) string { if s, ok := v.(string); ok { return s } return "" } func postTokenRequest(ctx context.Context, endpoint string, fields url.Values, headers map[string]string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(fields.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") for k, v := range headers { req.Header.Set(k, v) } resp, err := httpClientOIDC.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("token HTTP %d", resp.StatusCode) } var dec map[string]any if err := json.Unmarshal(body, &dec); err != nil { return "", err } tok, _ := dec["access_token"].(string) tok = strings.TrimSpace(tok) if tok == "" { return "", errors.New("нет access_token в ответе") } return tok, nil } // FindOrCreateAdminFromOIDC matches the Pocket ID profile to an existing admin by // username (preferred_username/name/email/sub, in that priority) or by e-mail. // If no admin exists yet at all, the very first successful OIDC login // auto-provisions the sole admin account (with a random, never-used local // password). Otherwise, an unmatched profile is rejected. func (r *Repo) FindOrCreateAdminFromOIDC(ctx context.Context, info UserInfo) (*models.User, error) { lookupName := info.LookupName() if lookupName == "" { return nil, errors.New("Pocket ID не вернул идентификатор пользователя.") } admin, err := r.GetAdminByUsername(ctx, lookupName) if err != nil { return nil, err } if admin == nil && info.Email != "" { admin, err = r.GetAdminByUsername(ctx, info.Email) if err != nil { return nil, err } } if admin == nil { count, err := r.AdminCount(ctx) if err != nil { return nil, err } if count != 0 { return nil, fmt.Errorf("пользователь %q не зарегистрирован как администратор", lookupName) } randomPass := make([]byte, 32) if _, err := rand.Read(randomPass); err != nil { return nil, err } hash, err := HashPassword(fmt.Sprintf("%x", randomPass)) if err != nil { return nil, err } admin, err = r.registerAdminWithHash(ctx, lookupName, hash) if err != nil { return nil, err } } if admin.Role != "admin" { return nil, errors.New("Учётная запись не является администратором.") } return admin, nil }