agentsclimarketplace

Implementing scalekit go auth

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

35 skills that teach AI coding agents to integrate Scalekit auth — agent auth, full-stack login, MCP OAuth 2.1, enterprise SSO, and SCIM. Works with Claude Code, Cursor, Windsurf, and 35+ other agents.

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.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

15.1 KB, 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
    }
    // ...
}

Keep looking

Skills are one crate of 328,083. 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.