| package handler |
|
|
| import ( |
| "context" |
| "crypto/hmac" |
| "crypto/sha256" |
| "encoding/base64" |
| "errors" |
| "fmt" |
| "log" |
| "net/http" |
| "net/url" |
| "strconv" |
| "strings" |
| "time" |
| "unicode/utf8" |
|
|
| dbent "github.com/Wei-Shaw/sub2api/ent" |
| dbuser "github.com/Wei-Shaw/sub2api/ent/user" |
| "github.com/Wei-Shaw/sub2api/internal/config" |
| infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" |
| "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" |
| "github.com/Wei-Shaw/sub2api/internal/pkg/response" |
| servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" |
| "github.com/Wei-Shaw/sub2api/internal/service" |
|
|
| "github.com/gin-gonic/gin" |
| "github.com/imroc/req/v3" |
| "github.com/tidwall/gjson" |
| ) |
|
|
| const ( |
| linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo" |
| oauthBindAccessTokenCookiePath = "/api/v1/auth/oauth" |
| linuxDoOAuthStateCookieName = "linuxdo_oauth_state" |
| linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier" |
| linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect" |
| linuxDoOAuthIntentCookieName = "linuxdo_oauth_intent" |
| linuxDoOAuthBindUserCookieName = "linuxdo_oauth_bind_user" |
| oauthBindAccessTokenCookieName = "oauth_bind_access_token" |
| linuxDoOAuthCookieMaxAgeSec = 10 * 60 |
| linuxDoOAuthDefaultRedirectTo = "/dashboard" |
| linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback" |
|
|
| linuxDoOAuthMaxRedirectLen = 2048 |
| linuxDoOAuthMaxFragmentValueLen = 512 |
| linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-") |
|
|
| oauthIntentLogin = "login" |
| oauthIntentBindCurrentUser = "bind_current_user" |
| ) |
|
|
| type linuxDoTokenResponse struct { |
| AccessToken string `json:"access_token"` |
| TokenType string `json:"token_type"` |
| ExpiresIn int64 `json:"expires_in"` |
| RefreshToken string `json:"refresh_token,omitempty"` |
| Scope string `json:"scope,omitempty"` |
| } |
|
|
| type linuxDoTokenExchangeError struct { |
| StatusCode int |
| ProviderError string |
| ProviderDescription string |
| Body string |
| } |
|
|
| func (e *linuxDoTokenExchangeError) Error() string { |
| if e == nil { |
| return "" |
| } |
| parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)} |
| if strings.TrimSpace(e.ProviderError) != "" { |
| parts = append(parts, "error="+strings.TrimSpace(e.ProviderError)) |
| } |
| if strings.TrimSpace(e.ProviderDescription) != "" { |
| parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription)) |
| } |
| return strings.Join(parts, " ") |
| } |
|
|
| |
| |
| func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { |
| cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| state, err := oauth.GenerateState() |
| if err != nil { |
| response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) |
| return |
| } |
|
|
| redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) |
| if redirectTo == "" { |
| redirectTo = linuxDoOAuthDefaultRedirectTo |
| } |
|
|
| browserSessionKey, err := generateOAuthPendingBrowserSession() |
| if err != nil { |
| response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err)) |
| return |
| } |
|
|
| secureCookie := isRequestHTTPS(c) |
| setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie) |
| setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie) |
| intent := normalizeOAuthIntent(c.Query("intent")) |
| setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie) |
| setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) |
| clearOAuthPendingSessionCookie(c, secureCookie) |
| if intent == oauthIntentBindCurrentUser { |
| bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
| setCookie(c, linuxDoOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), linuxDoOAuthCookieMaxAgeSec, secureCookie) |
| } else { |
| clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie) |
| } |
|
|
| codeChallenge := "" |
| if cfg.UsePKCE { |
| verifier, err := oauth.GenerateCodeVerifier() |
| if err != nil { |
| response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err)) |
| return |
| } |
| codeChallenge = oauth.GenerateCodeChallenge(verifier) |
| setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie) |
| } |
|
|
| redirectURI := strings.TrimSpace(cfg.RedirectURL) |
| if redirectURI == "" { |
| response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")) |
| return |
| } |
|
|
| authURL, err := buildLinuxDoAuthorizeURL(cfg, state, codeChallenge, redirectURI) |
| if err != nil { |
| response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) |
| return |
| } |
|
|
| c.Redirect(http.StatusFound, authURL) |
| } |
|
|
| |
| |
| func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { |
| cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context()) |
| if cfgErr != nil { |
| response.ErrorFrom(c, cfgErr) |
| return |
| } |
|
|
| frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) |
| if frontendCallback == "" { |
| frontendCallback = linuxDoOAuthDefaultFrontendCB |
| } |
|
|
| if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { |
| redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) |
| return |
| } |
|
|
| code := strings.TrimSpace(c.Query("code")) |
| state := strings.TrimSpace(c.Query("state")) |
| if code == "" || state == "" { |
| redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") |
| return |
| } |
|
|
| secureCookie := isRequestHTTPS(c) |
| defer func() { |
| clearCookie(c, linuxDoOAuthStateCookieName, secureCookie) |
| clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie) |
| clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie) |
| clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie) |
| clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie) |
| }() |
|
|
| expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName) |
| if err != nil || expectedState == "" || state != expectedState { |
| redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") |
| return |
| } |
|
|
| redirectTo, _ := readCookieDecoded(c, linuxDoOAuthRedirectCookie) |
| redirectTo = sanitizeFrontendRedirectPath(redirectTo) |
| if redirectTo == "" { |
| redirectTo = linuxDoOAuthDefaultRedirectTo |
| } |
| browserSessionKey, _ := readOAuthPendingBrowserCookie(c) |
| if strings.TrimSpace(browserSessionKey) == "" { |
| redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "") |
| return |
| } |
| intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName) |
| intent = normalizeOAuthIntent(intent) |
|
|
| codeVerifier := "" |
| if cfg.UsePKCE { |
| codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie) |
| if codeVerifier == "" { |
| redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "") |
| return |
| } |
| } |
|
|
| redirectURI := strings.TrimSpace(cfg.RedirectURL) |
| if redirectURI == "" { |
| redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "") |
| return |
| } |
|
|
| tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier) |
| if err != nil { |
| description := "" |
| var exchangeErr *linuxDoTokenExchangeError |
| if errors.As(err, &exchangeErr) && exchangeErr != nil { |
| log.Printf( |
| "[LinuxDo OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s", |
| exchangeErr.StatusCode, |
| exchangeErr.ProviderError, |
| exchangeErr.ProviderDescription, |
| truncateLogValue(exchangeErr.Body, 2048), |
| ) |
| description = exchangeErr.Error() |
| } else { |
| log.Printf("[LinuxDo OAuth] token exchange failed: %v", err) |
| description = err.Error() |
| } |
| redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description)) |
| return |
| } |
|
|
| email, username, subject, displayName, avatarURL, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp) |
| if err != nil { |
| log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err) |
| redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "") |
| return |
| } |
| compatEmail := strings.TrimSpace(email) |
|
|
| |
| |
| if subject != "" { |
| email = linuxDoSyntheticEmail(subject) |
| } |
| identityKey := service.PendingAuthIdentityKey{ |
| ProviderType: "linuxdo", |
| ProviderKey: "linuxdo", |
| ProviderSubject: subject, |
| } |
| upstreamClaims := map[string]any{ |
| "email": email, |
| "username": username, |
| "subject": subject, |
| "suggested_display_name": displayName, |
| "suggested_avatar_url": avatarURL, |
| } |
| if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) { |
| upstreamClaims["compat_email"] = compatEmail |
| } |
| if intent == oauthIntentBindCurrentUser { |
| targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName) |
| if err != nil { |
| redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "") |
| return |
| } |
| if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ |
| Intent: oauthIntentBindCurrentUser, |
| Identity: identityKey, |
| TargetUserID: &targetUserID, |
| ResolvedEmail: email, |
| RedirectTo: redirectTo, |
| BrowserSessionKey: browserSessionKey, |
| UpstreamIdentityClaims: upstreamClaims, |
| CompletionResponse: map[string]any{ |
| "redirect": redirectTo, |
| }, |
| }); err != nil { |
| redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "") |
| return |
| } |
| redirectToFrontendCallback(c, frontendCallback) |
| return |
| } |
|
|
| existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityKey) |
| if err != nil { |
| redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) |
| return |
| } |
| if existingIdentityUser != nil { |
| if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ |
| Intent: oauthIntentLogin, |
| Identity: identityKey, |
| TargetUserID: &existingIdentityUser.ID, |
| ResolvedEmail: existingIdentityUser.Email, |
| RedirectTo: redirectTo, |
| BrowserSessionKey: browserSessionKey, |
| UpstreamIdentityClaims: upstreamClaims, |
| CompletionResponse: map[string]any{ |
| "redirect": redirectTo, |
| }, |
| }); err != nil { |
| redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") |
| return |
| } |
| redirectToFrontendCallback(c, frontendCallback) |
| return |
| } |
|
|
| compatEmailUser, err := h.findLinuxDoCompatEmailUser(c.Request.Context(), compatEmail) |
| if err != nil { |
| redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) |
| return |
| } |
| if err := h.createLinuxDoOAuthChoicePendingSession( |
| c, |
| identityKey, |
| email, |
| email, |
| redirectTo, |
| browserSessionKey, |
| upstreamClaims, |
| compatEmail, |
| compatEmailUser, |
| h.isForceEmailOnThirdPartySignup(c.Request.Context()), |
| ); err != nil { |
| redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") |
| return |
| } |
| redirectToFrontendCallback(c, frontendCallback) |
| } |
|
|
| func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) { |
| client := h.entClient() |
| if client == nil { |
| return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") |
| } |
|
|
| email = strings.TrimSpace(strings.ToLower(email)) |
| if email == "" || |
| strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) || |
| strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) || |
| strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) || |
| strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) { |
| return nil, nil |
| } |
|
|
| userEntity, err := client.User.Query(). |
| Where(userNormalizedEmailPredicate(email)). |
| Order(dbent.Asc(dbuser.FieldID)). |
| All(ctx) |
| if err != nil { |
| return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err) |
| } |
| switch len(userEntity) { |
| case 0: |
| return nil, nil |
| case 1: |
| return userEntity[0], nil |
| default: |
| return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users") |
| } |
| } |
|
|
| func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession( |
| c *gin.Context, |
| identity service.PendingAuthIdentityKey, |
| suggestedEmail string, |
| resolvedEmail string, |
| redirectTo string, |
| browserSessionKey string, |
| upstreamClaims map[string]any, |
| compatEmail string, |
| compatEmailUser *dbent.User, |
| forceEmailOnSignup bool, |
| ) error { |
| suggestionEmail := strings.TrimSpace(suggestedEmail) |
| canonicalEmail := strings.TrimSpace(resolvedEmail) |
| if suggestionEmail == "" { |
| suggestionEmail = canonicalEmail |
| } |
|
|
| completionResponse := map[string]any{ |
| "step": oauthPendingChoiceStep, |
| "adoption_required": true, |
| "redirect": strings.TrimSpace(redirectTo), |
| "email": suggestionEmail, |
| "resolved_email": canonicalEmail, |
| "existing_account_email": "", |
| "existing_account_bindable": false, |
| "create_account_allowed": true, |
| "force_email_on_signup": forceEmailOnSignup, |
| "choice_reason": "third_party_signup", |
| } |
| if strings.TrimSpace(compatEmail) != "" { |
| completionResponse["compat_email"] = strings.TrimSpace(compatEmail) |
| } |
| resolvedChoiceEmail := suggestionEmail |
| if compatEmailUser != nil { |
| completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email) |
| completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email) |
| completionResponse["existing_account_bindable"] = true |
| completionResponse["choice_reason"] = "compat_email_match" |
| resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email) |
| } |
| if forceEmailOnSignup && compatEmailUser == nil { |
| completionResponse["choice_reason"] = "force_email_on_signup" |
| } |
|
|
| var targetUserID *int64 |
| if compatEmailUser != nil && compatEmailUser.ID > 0 { |
| targetUserID = &compatEmailUser.ID |
| } |
|
|
| return h.createOAuthPendingSession(c, oauthPendingSessionPayload{ |
| Intent: oauthIntentLogin, |
| Identity: identity, |
| TargetUserID: targetUserID, |
| ResolvedEmail: resolvedChoiceEmail, |
| RedirectTo: redirectTo, |
| BrowserSessionKey: browserSessionKey, |
| UpstreamIdentityClaims: upstreamClaims, |
| CompletionResponse: completionResponse, |
| }) |
| } |
|
|
| type completeLinuxDoOAuthRequest struct { |
| InvitationCode string `json:"invitation_code" binding:"required"` |
| AffCode string `json:"aff_code,omitempty"` |
| AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` |
| AdoptAvatar *bool `json:"adopt_avatar,omitempty"` |
| } |
|
|
| |
| |
| |
| func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) { |
| var req completeLinuxDoOAuthRequest |
| if err := c.ShouldBindJSON(&req); err != nil { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()}) |
| return |
| } |
|
|
| secureCookie := isRequestHTTPS(c) |
| sessionToken, err := readOAuthPendingSessionCookie(c) |
| if err != nil { |
| clearOAuthPendingSessionCookie(c, secureCookie) |
| clearOAuthPendingBrowserCookie(c, secureCookie) |
| response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound) |
| return |
| } |
| browserSessionKey, err := readOAuthPendingBrowserCookie(c) |
| if err != nil { |
| clearOAuthPendingSessionCookie(c, secureCookie) |
| clearOAuthPendingBrowserCookie(c, secureCookie) |
| response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch) |
| return |
| } |
| pendingSvc, err := h.pendingIdentityService() |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
| session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey) |
| if err != nil { |
| clearOAuthPendingSessionCookie(c, secureCookie) |
| clearOAuthPendingBrowserCookie(c, secureCookie) |
| response.ErrorFrom(c, err) |
| return |
| } |
| if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
| if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } else if handled { |
| c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession)) |
| return |
| } else { |
| session = updatedSession |
| } |
| if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| email := strings.TrimSpace(session.ResolvedEmail) |
| username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username") |
| if email == "" || username == "" { |
| response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")) |
| return |
| } |
|
|
| client := h.entClient() |
| if client == nil { |
| response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")) |
| return |
| } |
| if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil { |
| respondPendingOAuthBindingApplyError(c, err) |
| return |
| } |
| decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{ |
| AdoptDisplayName: req.AdoptDisplayName, |
| AdoptAvatar: req.AdoptAvatar, |
| }) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
| tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo") |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
| if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil { |
| respondPendingOAuthBindingApplyError(c, err) |
| return |
| } |
| h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) |
| clearOAuthPendingSessionCookie(c, secureCookie) |
| clearOAuthPendingBrowserCookie(c, secureCookie) |
|
|
| c.JSON(http.StatusOK, gin.H{ |
| "access_token": tokenPair.AccessToken, |
| "refresh_token": tokenPair.RefreshToken, |
| "expires_in": tokenPair.ExpiresIn, |
| "token_type": "Bearer", |
| }) |
| } |
|
|
| func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { |
| if h != nil && h.settingSvc != nil { |
| return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx) |
| } |
| if h == nil || h.cfg == nil { |
| return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") |
| } |
| if !h.cfg.LinuxDo.Enabled { |
| return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") |
| } |
| return h.cfg.LinuxDo, nil |
| } |
|
|
| func linuxDoExchangeCode( |
| ctx context.Context, |
| cfg config.LinuxDoConnectConfig, |
| code string, |
| redirectURI string, |
| codeVerifier string, |
| ) (*linuxDoTokenResponse, error) { |
| client := req.C().SetTimeout(30 * time.Second) |
|
|
| form := url.Values{} |
| form.Set("grant_type", "authorization_code") |
| form.Set("client_id", cfg.ClientID) |
| form.Set("code", code) |
| form.Set("redirect_uri", redirectURI) |
| if strings.TrimSpace(codeVerifier) != "" { |
| form.Set("code_verifier", codeVerifier) |
| } |
|
|
| r := client.R(). |
| SetContext(ctx). |
| SetHeader("Accept", "application/json") |
|
|
| switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) { |
| case "", "client_secret_post": |
| form.Set("client_secret", cfg.ClientSecret) |
| case "client_secret_basic": |
| r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret) |
| case "none": |
| default: |
| return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod) |
| } |
|
|
| resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL) |
| if err != nil { |
| return nil, fmt.Errorf("request token: %w", err) |
| } |
| body := strings.TrimSpace(resp.String()) |
| if !resp.IsSuccessState() { |
| providerErr, providerDesc := parseOAuthProviderError(body) |
| return nil, &linuxDoTokenExchangeError{ |
| StatusCode: resp.StatusCode, |
| ProviderError: providerErr, |
| ProviderDescription: providerDesc, |
| Body: body, |
| } |
| } |
|
|
| tokenResp, ok := parseLinuxDoTokenResponse(body) |
| if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" { |
| return nil, &linuxDoTokenExchangeError{ |
| StatusCode: resp.StatusCode, |
| Body: body, |
| } |
| } |
| if strings.TrimSpace(tokenResp.TokenType) == "" { |
| tokenResp.TokenType = "Bearer" |
| } |
| return tokenResp, nil |
| } |
|
|
| func linuxDoFetchUserInfo( |
| ctx context.Context, |
| cfg config.LinuxDoConnectConfig, |
| token *linuxDoTokenResponse, |
| ) (email string, username string, subject string, displayName string, avatarURL string, err error) { |
| client := req.C().SetTimeout(30 * time.Second) |
| authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken) |
| if err != nil { |
| return "", "", "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err) |
| } |
|
|
| resp, err := client.R(). |
| SetContext(ctx). |
| SetHeader("Accept", "application/json"). |
| SetHeader("Authorization", authorization). |
| Get(cfg.UserInfoURL) |
| if err != nil { |
| return "", "", "", "", "", fmt.Errorf("request userinfo: %w", err) |
| } |
| if !resp.IsSuccessState() { |
| return "", "", "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode) |
| } |
|
|
| return linuxDoParseUserInfo(resp.String(), cfg) |
| } |
|
|
| func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, displayName string, avatarURL string, err error) { |
| email = firstNonEmpty( |
| getGJSON(body, cfg.UserInfoEmailPath), |
| getGJSON(body, "email"), |
| getGJSON(body, "user.email"), |
| getGJSON(body, "data.email"), |
| getGJSON(body, "attributes.email"), |
| ) |
| username = firstNonEmpty( |
| getGJSON(body, cfg.UserInfoUsernamePath), |
| getGJSON(body, "username"), |
| getGJSON(body, "preferred_username"), |
| getGJSON(body, "name"), |
| getGJSON(body, "user.username"), |
| getGJSON(body, "user.name"), |
| ) |
| subject = firstNonEmpty( |
| getGJSON(body, cfg.UserInfoIDPath), |
| getGJSON(body, "sub"), |
| getGJSON(body, "id"), |
| getGJSON(body, "user_id"), |
| getGJSON(body, "uid"), |
| getGJSON(body, "user.id"), |
| ) |
|
|
| displayName = firstNonEmpty( |
| getGJSON(body, "name"), |
| getGJSON(body, "nickname"), |
| getGJSON(body, "display_name"), |
| getGJSON(body, "user.name"), |
| getGJSON(body, "user.username"), |
| username, |
| ) |
| avatarURL = firstNonEmpty( |
| getGJSON(body, "avatar_url"), |
| getGJSON(body, "avatar"), |
| getGJSON(body, "picture"), |
| getGJSON(body, "profile_image_url"), |
| getGJSON(body, "user.avatar"), |
| getGJSON(body, "user.avatar_url"), |
| ) |
|
|
| subject = strings.TrimSpace(subject) |
| if subject == "" { |
| return "", "", "", "", "", errors.New("userinfo missing id field") |
| } |
| if !isSafeLinuxDoSubject(subject) { |
| return "", "", "", "", "", errors.New("userinfo returned invalid id field") |
| } |
|
|
| email = strings.TrimSpace(email) |
| if email == "" { |
| |
| email = linuxDoSyntheticEmail(subject) |
| } |
|
|
| username = strings.TrimSpace(username) |
| if username == "" { |
| username = "linuxdo_" + subject |
| } |
| displayName = strings.TrimSpace(displayName) |
| if displayName == "" { |
| displayName = username |
| } |
| avatarURL = strings.TrimSpace(avatarURL) |
|
|
| return email, username, subject, displayName, avatarURL, nil |
| } |
|
|
| func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, codeChallenge string, redirectURI string) (string, error) { |
| u, err := url.Parse(cfg.AuthorizeURL) |
| if err != nil { |
| return "", fmt.Errorf("parse authorize_url: %w", err) |
| } |
|
|
| q := u.Query() |
| q.Set("response_type", "code") |
| q.Set("client_id", cfg.ClientID) |
| q.Set("redirect_uri", redirectURI) |
| if strings.TrimSpace(cfg.Scopes) != "" { |
| q.Set("scope", cfg.Scopes) |
| } |
| q.Set("state", state) |
| if strings.TrimSpace(codeChallenge) != "" { |
| q.Set("code_challenge", codeChallenge) |
| q.Set("code_challenge_method", "S256") |
| } |
|
|
| u.RawQuery = q.Encode() |
| return u.String(), nil |
| } |
|
|
| func redirectOAuthError(c *gin.Context, frontendCallback string, code string, message string, description string) { |
| fragment := url.Values{} |
| fragment.Set("error", truncateFragmentValue(code)) |
| if strings.TrimSpace(message) != "" { |
| fragment.Set("error_message", truncateFragmentValue(message)) |
| } |
| if strings.TrimSpace(description) != "" { |
| fragment.Set("error_description", truncateFragmentValue(description)) |
| } |
| redirectWithFragment(c, frontendCallback, fragment) |
| } |
|
|
| func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) { |
| u, err := url.Parse(frontendCallback) |
| if err != nil { |
| |
| c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) |
| return |
| } |
| if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { |
| c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) |
| return |
| } |
| u.Fragment = fragment.Encode() |
| c.Header("Cache-Control", "no-store") |
| c.Header("Pragma", "no-cache") |
| c.Redirect(http.StatusFound, u.String()) |
| } |
|
|
| func firstNonEmpty(values ...string) string { |
| for _, v := range values { |
| v = strings.TrimSpace(v) |
| if v != "" { |
| return v |
| } |
| } |
| return "" |
| } |
|
|
| func parseOAuthProviderError(body string) (providerErr string, providerDesc string) { |
| body = strings.TrimSpace(body) |
| if body == "" { |
| return "", "" |
| } |
|
|
| providerErr = firstNonEmpty( |
| getGJSON(body, "error"), |
| getGJSON(body, "code"), |
| getGJSON(body, "error.code"), |
| ) |
| providerDesc = firstNonEmpty( |
| getGJSON(body, "error_description"), |
| getGJSON(body, "error.message"), |
| getGJSON(body, "message"), |
| getGJSON(body, "detail"), |
| ) |
|
|
| if providerErr != "" || providerDesc != "" { |
| return providerErr, providerDesc |
| } |
|
|
| values, err := url.ParseQuery(body) |
| if err != nil { |
| return "", "" |
| } |
| providerErr = firstNonEmpty(values.Get("error"), values.Get("code")) |
| providerDesc = firstNonEmpty(values.Get("error_description"), values.Get("error_message"), values.Get("message")) |
| return providerErr, providerDesc |
| } |
|
|
| func parseLinuxDoTokenResponse(body string) (*linuxDoTokenResponse, bool) { |
| body = strings.TrimSpace(body) |
| if body == "" { |
| return nil, false |
| } |
|
|
| accessToken := strings.TrimSpace(getGJSON(body, "access_token")) |
| if accessToken != "" { |
| tokenType := strings.TrimSpace(getGJSON(body, "token_type")) |
| refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token")) |
| scope := strings.TrimSpace(getGJSON(body, "scope")) |
| expiresIn := gjson.Get(body, "expires_in").Int() |
| return &linuxDoTokenResponse{ |
| AccessToken: accessToken, |
| TokenType: tokenType, |
| ExpiresIn: expiresIn, |
| RefreshToken: refreshToken, |
| Scope: scope, |
| }, true |
| } |
|
|
| values, err := url.ParseQuery(body) |
| if err != nil { |
| return nil, false |
| } |
| accessToken = strings.TrimSpace(values.Get("access_token")) |
| if accessToken == "" { |
| return nil, false |
| } |
| expiresIn := int64(0) |
| if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" { |
| if v, err := strconv.ParseInt(raw, 10, 64); err == nil { |
| expiresIn = v |
| } |
| } |
| return &linuxDoTokenResponse{ |
| AccessToken: accessToken, |
| TokenType: strings.TrimSpace(values.Get("token_type")), |
| ExpiresIn: expiresIn, |
| RefreshToken: strings.TrimSpace(values.Get("refresh_token")), |
| Scope: strings.TrimSpace(values.Get("scope")), |
| }, true |
| } |
|
|
| func getGJSON(body string, path string) string { |
| path = strings.TrimSpace(path) |
| if path == "" { |
| return "" |
| } |
| res := gjson.Get(body, path) |
| if !res.Exists() { |
| return "" |
| } |
| return res.String() |
| } |
|
|
| func truncateLogValue(value string, maxLen int) string { |
| value = strings.TrimSpace(value) |
| if value == "" || maxLen <= 0 { |
| return "" |
| } |
| if len(value) <= maxLen { |
| return value |
| } |
| value = value[:maxLen] |
| for !utf8.ValidString(value) { |
| value = value[:len(value)-1] |
| } |
| return value |
| } |
|
|
| func singleLine(value string) string { |
| value = strings.TrimSpace(value) |
| if value == "" { |
| return "" |
| } |
| return strings.Join(strings.Fields(value), " ") |
| } |
|
|
| func sanitizeFrontendRedirectPath(path string) string { |
| path = strings.TrimSpace(path) |
| if path == "" { |
| return "" |
| } |
| if len(path) > linuxDoOAuthMaxRedirectLen { |
| return "" |
| } |
| |
| if !strings.HasPrefix(path, "/") { |
| return "" |
| } |
| if strings.HasPrefix(path, "//") { |
| return "" |
| } |
| if strings.Contains(path, "://") { |
| return "" |
| } |
| if strings.ContainsAny(path, "\r\n") { |
| return "" |
| } |
| return path |
| } |
|
|
| func isRequestHTTPS(c *gin.Context) bool { |
| if c.Request.TLS != nil { |
| return true |
| } |
| proto := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))) |
| return proto == "https" |
| } |
|
|
| func encodeCookieValue(value string) string { |
| return base64.RawURLEncoding.EncodeToString([]byte(value)) |
| } |
|
|
| func decodeCookieValue(value string) (string, error) { |
| raw, err := base64.RawURLEncoding.DecodeString(value) |
| if err != nil { |
| return "", err |
| } |
| return string(raw), nil |
| } |
|
|
| func readCookieDecoded(c *gin.Context, name string) (string, error) { |
| ck, err := c.Request.Cookie(name) |
| if err != nil { |
| return "", err |
| } |
| return decodeCookieValue(ck.Value) |
| } |
|
|
| func setCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) { |
| http.SetCookie(c.Writer, &http.Cookie{ |
| Name: name, |
| Value: value, |
| Path: linuxDoOAuthCookiePath, |
| MaxAge: maxAgeSec, |
| HttpOnly: true, |
| Secure: secure, |
| SameSite: http.SameSiteLaxMode, |
| }) |
| } |
|
|
| func clearCookie(c *gin.Context, name string, secure bool) { |
| http.SetCookie(c.Writer, &http.Cookie{ |
| Name: name, |
| Value: "", |
| Path: linuxDoOAuthCookiePath, |
| MaxAge: -1, |
| HttpOnly: true, |
| Secure: secure, |
| SameSite: http.SameSiteLaxMode, |
| }) |
| } |
|
|
| func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) { |
| http.SetCookie(c.Writer, &http.Cookie{ |
| Name: oauthBindAccessTokenCookieName, |
| Value: "", |
| Path: oauthBindAccessTokenCookiePath, |
| MaxAge: -1, |
| HttpOnly: true, |
| Secure: secure, |
| SameSite: http.SameSiteLaxMode, |
| }) |
| } |
|
|
| func setOAuthBindAccessTokenCookie(c *gin.Context, token string, secure bool) { |
| http.SetCookie(c.Writer, &http.Cookie{ |
| Name: oauthBindAccessTokenCookieName, |
| Value: url.QueryEscape(strings.TrimSpace(token)), |
| Path: oauthBindAccessTokenCookiePath, |
| MaxAge: linuxDoOAuthCookieMaxAgeSec, |
| HttpOnly: true, |
| Secure: secure, |
| SameSite: http.SameSiteLaxMode, |
| }) |
| } |
|
|
| func truncateFragmentValue(value string) string { |
| value = strings.TrimSpace(value) |
| if value == "" { |
| return "" |
| } |
| if len(value) > linuxDoOAuthMaxFragmentValueLen { |
| value = value[:linuxDoOAuthMaxFragmentValueLen] |
| for !utf8.ValidString(value) { |
| value = value[:len(value)-1] |
| } |
| } |
| return value |
| } |
|
|
| func buildBearerAuthorization(tokenType, accessToken string) (string, error) { |
| tokenType = strings.TrimSpace(tokenType) |
| if tokenType == "" { |
| tokenType = "Bearer" |
| } |
| if !strings.EqualFold(tokenType, "Bearer") { |
| return "", fmt.Errorf("unsupported token_type: %s", tokenType) |
| } |
|
|
| accessToken = strings.TrimSpace(accessToken) |
| if accessToken == "" { |
| return "", errors.New("missing access_token") |
| } |
| if strings.ContainsAny(accessToken, " \t\r\n") { |
| return "", errors.New("access_token contains whitespace") |
| } |
| return "Bearer " + accessToken, nil |
| } |
|
|
| func isSafeLinuxDoSubject(subject string) bool { |
| subject = strings.TrimSpace(subject) |
| if subject == "" || len(subject) > linuxDoOAuthMaxSubjectLen { |
| return false |
| } |
| for _, r := range subject { |
| switch { |
| case r >= '0' && r <= '9': |
| case r >= 'a' && r <= 'z': |
| case r >= 'A' && r <= 'Z': |
| case r == '_' || r == '-': |
| default: |
| return false |
| } |
| } |
| return true |
| } |
|
|
| func linuxDoSyntheticEmail(subject string) string { |
| subject = strings.TrimSpace(subject) |
| if subject == "" { |
| return "" |
| } |
| return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain |
| } |
|
|
| func normalizeOAuthIntent(raw string) string { |
| switch strings.ToLower(strings.TrimSpace(raw)) { |
| case "", oauthIntentLogin: |
| return oauthIntentLogin |
| case "bind", oauthIntentBindCurrentUser: |
| return oauthIntentBindCurrentUser |
| default: |
| return oauthIntentLogin |
| } |
| } |
|
|
| func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (string, error) { |
| userID, err := h.resolveOAuthBindTargetUserID(c) |
| if err != nil || userID == nil || *userID <= 0 { |
| return "", infraerrors.Unauthorized("UNAUTHORIZED", "authentication required") |
| } |
| return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret()) |
| } |
|
|
| func (h *AuthHandler) PrepareOAuthBindAccessTokenCookie(c *gin.Context) { |
| const bearerPrefix = "Bearer " |
|
|
| authHeader := strings.TrimSpace(c.GetHeader("Authorization")) |
| if !strings.HasPrefix(strings.ToLower(authHeader), strings.ToLower(bearerPrefix)) { |
| response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")) |
| return |
| } |
|
|
| token := strings.TrimSpace(authHeader[len(bearerPrefix):]) |
| if token == "" { |
| response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")) |
| return |
| } |
|
|
| setOAuthBindAccessTokenCookie(c, token, isRequestHTTPS(c)) |
| c.Status(http.StatusNoContent) |
| c.Writer.WriteHeaderNow() |
| } |
|
|
| func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) { |
| if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { |
| return &subject.UserID, nil |
| } |
| if h == nil || h.authService == nil || h.userService == nil { |
| return nil, service.ErrInvalidToken |
| } |
|
|
| ck, err := c.Request.Cookie(oauthBindAccessTokenCookieName) |
| clearOAuthBindAccessTokenCookie(c, isRequestHTTPS(c)) |
| if err != nil { |
| return nil, err |
| } |
|
|
| tokenString, err := url.QueryUnescape(strings.TrimSpace(ck.Value)) |
| if err != nil { |
| return nil, err |
| } |
| if tokenString == "" { |
| return nil, service.ErrInvalidToken |
| } |
|
|
| claims, err := h.authService.ValidateToken(tokenString) |
| if err != nil { |
| return nil, err |
| } |
| user, err := h.userService.GetByID(c.Request.Context(), claims.UserID) |
| if err != nil { |
| return nil, err |
| } |
| if user == nil || !user.IsActive() || claims.TokenVersion != user.TokenVersion { |
| return nil, service.ErrInvalidToken |
| } |
| return &user.ID, nil |
| } |
|
|
| func (h *AuthHandler) readOAuthBindUserIDFromCookie(c *gin.Context, cookieName string) (int64, error) { |
| value, err := readCookieDecoded(c, cookieName) |
| if err != nil { |
| return 0, err |
| } |
| return parseOAuthBindUserCookieValue(value, h.oauthBindCookieSecret()) |
| } |
|
|
| func (h *AuthHandler) oauthBindCookieSecret() string { |
| if h == nil || h.cfg == nil { |
| return "" |
| } |
| return strings.TrimSpace(h.cfg.JWT.Secret) |
| } |
|
|
| func buildOAuthBindUserCookieValue(userID int64, secret string) (string, error) { |
| secret = strings.TrimSpace(secret) |
| if userID <= 0 || secret == "" { |
| return "", errors.New("invalid oauth bind cookie input") |
| } |
| payload := strconv.FormatInt(userID, 10) |
| mac := hmac.New(sha256.New, []byte(secret)) |
| _, _ = mac.Write([]byte(payload)) |
| signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) |
| return payload + "." + signature, nil |
| } |
|
|
| func parseOAuthBindUserCookieValue(value string, secret string) (int64, error) { |
| secret = strings.TrimSpace(secret) |
| if secret == "" { |
| return 0, errors.New("missing oauth bind cookie secret") |
| } |
| payload, signature, ok := strings.Cut(strings.TrimSpace(value), ".") |
| if !ok || payload == "" || signature == "" { |
| return 0, errors.New("invalid oauth bind cookie") |
| } |
| mac := hmac.New(sha256.New, []byte(secret)) |
| _, _ = mac.Write([]byte(payload)) |
| expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) |
| if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { |
| return 0, errors.New("invalid oauth bind cookie signature") |
| } |
| userID, err := strconv.ParseInt(payload, 10, 64) |
| if err != nil || userID <= 0 { |
| return 0, errors.New("invalid oauth bind cookie user") |
| } |
| return userID, nil |
| } |
|
|