agentsclimarketplace

Implementing scalekit go auth

Skill scalekit-inc/skills/skills/implementing-scalekit-go-auth

Guides Go developers implementing Scalekit authentication in Gin-based web apps using scalekit-sdk-go. Use when the developer mentions Scalekit, enterprise SSO, OIDC login, OAuth2 callback, access token validation, token refresh, session cookies, logout, IDP-initiated login, or xoid/xuid JWT claims in a Go project.From its SKILL.md

Install
npx -y skills add scalekit-inc/skills --skill implementing-scalekit-go-auth

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

15.1 KB, ~3.7k tokens by cl100k_base, as published. Nobody here has run it

Scalekit Auth in Go (Gin)

Scalekit is an OIDC/OAuth2 provider. Unlike frameworks that auto-wire OAuth2, Go requires you to manually implement four handlers: authorize → callback → session → logout. Use scalekit-sdk-go/v2.

Dependencies

go get github.com/scalekit-inc/scalekit-sdk-go/v2
go get github.com/gin-gonic/gin
go get github.com/gin-contrib/cors
go get github.com/golang-jwt/jwt/v5

Environment variables

SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev
SCALEKIT_CLIENT_ID=your_client_id
SCALEKIT_CLIENT_SECRET=your_client_secret
PORT=8080

Never commit secrets. Load with godotenv or equivalent.

Global client — initialize once

Use sync.Once so the client is created exactly once across all requests:

var (
    globalClient scalekit.Scalekit
    clientOnce   sync.Once
    clientErr    error
)

func GetScaleKitClient() (scalekit.Scalekit, error) {
    clientOnce.Do(func() {
        envURL  := os.Getenv("SCALEKIT_ENVIRONMENT_URL")
        id      := os.Getenv("SCALEKIT_CLIENT_ID")
        secret  := os.Getenv("SCALEKIT_CLIENT_SECRET")
        globalClient = scalekit.NewScalekitClient(envURL, id, secret)
    })
    return globalClient, clientErr
}

Call GetScaleKitClient() once at startup to fail fast on bad credentials.

Auth flow at a glance

GET /api/authorize
  → GetAuthorizationUrl()  → 302 to Scalekit

GET /api/scalekit/callback?code=...
  → AuthenticateWithCode() → redirect to /dashboard or /onboarding

GET /api/session           (every page load)
  → ValidateAccessToken()  → refresh if expired → return user JSON

GET /api/logout
  → GetLogoutUrl()         → clear cookies → 302 to Scalekit end-session

Handler: Authorize

Builds the authorization URL and redirects the browser:

func AuthorizeHandler(c *gin.Context) {
    sc, _ := GetScaleKitClient()

    stateBytes, _ := json.Marshal(map[string]any{
        "next": c.Query("next"),
        "csrf": randomString(12),
    })
    state := base64.StdEncoding.EncodeToString(stateBytes)

    opts := scalekit.AuthorizationUrlOptions{
        State:  state,
        Scopes: []string{"openid", "profile", "email", "offline_access"},
    }
    // Scope to a specific org, connection, or hint when provided
    if v := c.Query("organization_id"); v != "" { opts.OrganizationId = v }
    if v := c.Query("connection_id");   v != "" { opts.ConnectionId   = v }
    if v := c.Query("login_hint");      v != "" { opts.LoginHint      = v }

    authURL, err := sc.GetAuthorizationUrl(callbackURL(c), opts)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to build authorization URL"})
        return
    }
    c.Redirect(http.StatusFound, authURL.String())
}

func callbackURL(c *gin.Context) string {
    proto := "https"
    if strings.Contains(c.Request.Host, "localhost") { proto = "http" }
    return proto + "://" + c.Request.Host + "/api/scalekit/callback"
}

Handler: Callback

Exchange the authorization code for tokens; set httpOnly cookies:

func CallbackHandler(c *gin.Context) {
    if e := c.Query("error"); e != "" {
        c.JSON(400, gin.H{"error": c.Query("error_description")})
        return
    }

    sc, _ := GetScaleKitClient()
    resp, err := sc.AuthenticateWithCode(
        c.Request.Context(),
        c.Query("code"),
        callbackURL(c),
        scalekit.AuthenticationOptions{},
    )
    if err != nil {
        c.JSON(500, gin.H{"error": "Token exchange failed"})
        return
    }

    c.SetCookie("auth_access_token",  resp.AccessToken,  86400,   "/", "", false, true)
    c.SetCookie("auth_refresh_token", resp.RefreshToken, 2592000, "/", "", false, true)
    c.SetCookie("id_token",           resp.IdToken,      86400,   "/", "", false, false)

    // Route: no org in token → new user needs onboarding
    claims, _ := decodeJWTPayload(resp.AccessToken)
    redirect := "/onboarding"
    if _, hasOrg := claims["xoid"]; hasOrg {
        redirect = "/dashboard"
    }
    c.Redirect(http.StatusFound, getUIBaseURL(c)+redirect)
}

resp fields: AccessToken, RefreshToken, IdToken, User (email, name, etc.).

Handler: Session

Validate on every authenticated page load; silently refresh expired tokens:

func SessionHandler(c *gin.Context) {
    accessToken,  _ := c.Cookie("auth_access_token")
    refreshToken, _ := c.Cookie("auth_refresh_token")

    sc, _ := GetScaleKitClient()

    valid, err := sc.ValidateAccessToken(c.Request.Context(), accessToken)
    if err != nil || !valid {
        refreshed, err := sc.RefreshAccessToken(c.Request.Context(), refreshToken)
        if err != nil {
            LogoutHandler(c) // force re-login
            return
        }
        c.SetCookie("auth_access_token",  refreshed.AccessToken,  86400,   "/", "", false, true)
        c.SetCookie("auth_refresh_token", refreshed.RefreshToken, 2592000, "/", "", false, true)
        accessToken = refreshed.AccessToken
    }

    claims, _ := decodeJWTPayload(accessToken)
    userID, _  := getStringClaim(claims, "sub")

    userResp, _ := sc.User().GetUser(context.Background(), userID)
    c.JSON(200, gin.H{
        "authenticated": true,
        "user": gin.H{
            "id":         userResp.User.Id,
            "email":      userResp.User.Email,
            "first_name": userResp.User.UserProfile.FirstName,
            "last_name":  userResp.User.UserProfile.LastName,
        },
    })
}

Handler: Logout

Clear all cookies and redirect to Scalekit's end-session endpoint:

func LogoutHandler(c *gin.Context) {
    idToken, _ := c.Cookie("id_token")
    sc, _      := GetScaleKitClient()

    logoutURL, _ := sc.GetLogoutUrl(scalekit.LogoutUrlOptions{
        IdTokenHint:           idToken,
        PostLogoutRedirectUri: getUIBaseURL(c),
    })

    c.SetCookie("auth_access_token",  "", -1, "/", "", false, true)
    c.SetCookie("auth_refresh_token", "", -1, "/", "", false, true)
    c.SetCookie("id_token",           "", -1, "/", "", false, false)
    c.Redirect(http.StatusFound, logoutURL.String())
}

Handler: IDP-initiated login (enterprise SSO)

When an IdP starts the login (e.g. Okta tile click), Scalekit sends a signed JWT:

func IdpInitiatedLoginHandler(c *gin.Context) {
    sc, _ := GetScaleKitClient()
    claims, err := sc.GetIdpInitiatedLoginClaims(
        c.Request.Context(),
        c.Query("idp_initiated_login"),
    )
    if err != nil {
        c.JSON(400, gin.H{"error": "invalid idp_initiated_login token"})
        return
    }
    opts := scalekit.AuthorizationUrlOptions{
        Scopes: []string{"openid", "profile", "email", "offline_access"},
    }
    if claims.OrganizationID != "" { opts.OrganizationId = claims.OrganizationID }
    if claims.ConnectionID   != "" { opts.ConnectionId   = claims.ConnectionID   }
    if claims.LoginHint      != "" { opts.LoginHint      = claims.LoginHint      }

    authURL, _ := sc.GetAuthorizationUrl(callbackURL(c), opts)
    c.Redirect(http.StatusFound, authURL.String())
}

JWT utility helpers

// decodeJWTPayload decodes the payload of a JWT without verifying the signature.
// Always use ValidateAccessToken() for security — this is only for claim extraction after validation.
func decodeJWTPayload(token string) (map[string]interface{}, error) {
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        return nil, fmt.Errorf("invalid JWT format")
    }
    payload, err := base64.RawURLEncoding.DecodeString(parts[1])
    if err != nil {
        return nil, err
    }
    var claims map[string]interface{}
    return claims, json.Unmarshal(payload, &claims)
}

func getStringClaim(claims map[string]interface{}, key string) (string, error) {
    v, ok := claims[key].(string)
    if !ok || v == "" {
        return "", fmt.Errorf("claim %q missing or empty", key)
    }
    return v, nil
}

Scalekit JWT claims reference

ClaimMeaningNotes
subScalekit user IDAlways present
xoidExternal org ID (e.g. wspace_abc)Absent → user has no org yet → send to /onboarding
xuidYour app's user DB IDAbsent → create user locally, then call UpdateUser to write it back
permissionsUser permissions in orgCheck before authorizing sensitive actions
rolesUser roles in orgDerive is_admin from role names

Route registration

api := r.Group("/api")
api.GET("/authorize",         AuthorizeHandler)
api.GET("/login/initiate",    IdpInitiatedLoginHandler)
api.GET("/scalekit/callback", CallbackHandler)
api.GET("/session",           SessionHandler)
api.GET("/logout",            LogoutHandler)

CORS — required for cookie-based auth

r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"https://yourdomain.com"},
    AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
    AllowCredentials: true, // MUST be true when cookies carry tokens
    MaxAge:           12 * time.Hour,
}))

Implementation checklist

- [ ] Step 1: go get scalekit-sdk-go/v2, gin, cors, jwt/v5
- [ ] Step 2: Set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET in .env
- [ ] Step 3: Create handlers/client.go — sync.Once singleton
- [ ] Step 4: Create handlers/utils.go — decodeJWTPayload, getStringClaim, callbackURL, getUIBaseURL
- [ ] Step 5: Implement AuthorizeHandler → GetAuthorizationUrl → redirect
- [ ] Step 6: Implement CallbackHandler → AuthenticateWithCode → set cookies → redirect
- [ ] Step 7: Implement SessionHandler → ValidateAccessToken → RefreshAccessToken if expired
- [ ] Step 8: Implement LogoutHandler → GetLogoutUrl → clear cookies → redirect
- [ ] Step 9: Register all four routes under /api
- [ ] Step 10: Configure CORS with AllowCredentials: true
- [ ] Step 11: Register callback URI in Scalekit dashboard
- [ ] Step 12: Test: login → /dashboard → GET /api/session → logout

Troubleshooting

invalid_grant on callback: The redirectURL in AuthenticateWithCode must exactly match the URI registered in the Scalekit dashboard — including scheme and path. One mismatch silently breaks the exchange.

Session handler stuck in logout loop: ValidateAccessToken returns false on both expiry and network errors. Log err before deciding to refresh vs. logout so you can distinguish the two.

xoid missing: The user has no organization. This is expected for new signups — route to /onboarding to create or join a workspace.

CORS / cookie not sent: Ensure AllowCredentials: true is set in CORS config. Without it, the browser strips cookies from cross-origin requests.

toExternalWorkspaceID format: Internal org IDs are org_<id>. Strip the prefix and prepend wspace_ to get the external workspace ID used in the access token's xoid claim.

Reference

Tactics

SameSite=Lax — set explicitly on each cookie

Gin's c.SetCookie does not expose a SameSite parameter. Use http.SetCookie directly for full control:

http.SetCookie(c.Writer, &http.Cookie{
    Name:     "auth_access_token",
    Value:    resp.AccessToken,
    Path:     "/",
    MaxAge:   86400,
    HttpOnly: true,
    SameSite: http.SameSiteLaxMode, // Required — Strict drops cookie on OAuth redirect back
    Secure:   !strings.Contains(c.Request.Host, "localhost"),
})

SameSite: Strict drops the session cookie on the cross-origin redirect from Scalekit back to /api/scalekit/callback — the callback receives no cookies and the auth flow breaks silently.

Secure flag in production

Never hardcode secure: false. Detect localhost at runtime:

func isSecure(c *gin.Context) bool {
    return !strings.Contains(c.Request.Host, "localhost")
}

Pass Secure: isSecure(c) when setting every cookie. This ensures Secure is always set in production (HTTPS) without breaking local development.

CSRF via state parameter

The base64-encoded state in AuthorizeHandler already carries a CSRF token ("csrf": randomString(12)). Validate it in CallbackHandler before exchanging the code:

stateRaw, err := base64.StdEncoding.DecodeString(c.Query("state"))
if err != nil {
    c.JSON(400, gin.H{"error": "invalid state"})
    return
}
var stateData map[string]string
json.Unmarshal(stateRaw, &stateData)
// Optionally compare stateData["csrf"] against a cookie set in AuthorizeHandler

For stronger CSRF protection, store the csrf value in a short-lived cookie in AuthorizeHandler and verify it matches in CallbackHandler.

Deep link preservation via state

The state JSON already includes "next". After a successful callback, extract it and redirect:

next := stateData["next"]
if next == "" || !strings.HasPrefix(next, "/") {
    next = "/dashboard" // prevent open redirect
}
c.Redirect(http.StatusFound, getUIBaseURL(c)+next)

Never redirect to an absolute URL from state — only relative paths starting with /.

Cache-Control: no-store on protected endpoints

After logout, the browser back button can serve a cached /api/session response showing the user as authenticated. Add the header on every session/protected response:

func SessionHandler(c *gin.Context) {
    c.Header("Cache-Control", "no-store")
    // ...
}

Token refresh race condition

Multiple browser tabs hitting /api/session simultaneously can each attempt a refresh with the same refresh token — the second call will receive invalid_grant. Use a per-user mutex or a distributed lock:

var refreshMu sync.Map // keyed by refresh token hash

func SessionHandler(c *gin.Context) {
    refreshToken, _ := c.Cookie("auth_refresh_token")
    key := fmt.Sprintf("%x", sha256.Sum256([]byte(refreshToken)))

    mu, _ := refreshMu.LoadOrStore(key, &sync.Mutex{})
    mu.(*sync.Mutex).Lock()
    defer mu.(*sync.Mutex).Unlock()
    // ...refresh logic...
}

For stateless deployments, treat invalid_grant on refresh as a session expiry and redirect to login rather than erroring.

401 vs redirect for JSON clients

If a JavaScript frontend calls /api/session and gets a 302 redirect, the browser follows it silently and the client receives HTML instead of JSON. Return 401 for Accept: application/json requests:

func SessionHandler(c *gin.Context) {
    accessToken, err := c.Cookie("auth_access_token")
    if err != nil || accessToken == "" {
        if strings.Contains(c.GetHeader("Accept"), "application/json") {
            c.JSON(401, gin.H{"error": "unauthenticated"})
        } else {
            c.Redirect(http.StatusFound, "/login")
        }
        return
    }
    // ...
}

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.