// auth_oauth2 — OAuth2 auth primitive // // 讀取 auth_recipe:{service}(含 token_endpoint、client_id、client_secret) // + 解密 refresh_token + POST token endpoint 換 access_token // + 展開 {{runtime.access_token}} 模板。 // // Actions: // - authenticate: 用 refresh_token 換 access_token,回傳注入後的 headers/query/body // - needs_refresh: 檢查 access_token 是否快過期(expires_at < now+300s) // - refresh: 強制重新 refresh,更新 KV 中的 cached_access_token/expires_at // // Host imports: // - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV // - u6u.kv_put — 寫回 cached_access_token/expires_at // - u6u.crypto_decrypt — AES-GCM 解密 refresh_token // - u6u.http_request — POST token endpoint // //go:build tinygo package main import ( "encoding/json" "io" "net/url" "os" "strconv" "strings" "time" "unsafe" ) // ── host function 宣告 ─────────────────────────────────────────────────────── //go:wasmimport u6u kv_get func hostKvGet( keyPtr uintptr, keyLen uint32, outPtr uintptr, outLenPtr uintptr, ) uint32 //go:wasmimport u6u kv_put func hostKvPut( keyPtr uintptr, keyLen uint32, valPtr uintptr, valLen uint32, ttlSeconds uint32, ) uint32 //go:wasmimport u6u crypto_decrypt func hostCryptoDecrypt( encPtr uintptr, encLen uint32, ivPtr uintptr, ivLen uint32, outPtr uintptr, outLenPtr uintptr, ) uint32 //go:wasmimport u6u http_request func hostHttpRequest( urlPtr uintptr, urlLen uint32, methodPtr uintptr, methodLen uint32, headersPtr uintptr, headersLen uint32, bodyPtr uintptr, bodyLen uint32, outPtr uintptr, outLenPtr uintptr, ) uint32 // ── 型別 ───────────────────────────────────────────────────────────────────── type Input struct { Action string `json:"action"` APIKey string `json:"api_key"` Service string `json:"service"` Request json.RawMessage `json:"request,omitempty"` } type SecretRequirement struct { Key string `json:"key"` Label string `json:"label"` Optional bool `json:"optional,omitempty"` } type AuthInjectSpec struct { Header map[string]string `json:"header,omitempty"` Query map[string]string `json:"query,omitempty"` Body map[string]string `json:"body,omitempty"` } type OAuth2Config struct { TokenEndpoint string `json:"token_endpoint"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` Scopes []string `json:"scopes,omitempty"` } type AuthRecipe struct { Kind string `json:"kind"` Service string `json:"service"` Primitive string `json:"primitive"` OAuth2 *OAuth2Config `json:"oauth2,omitempty"` RequiredSecrets []SecretRequirement `json:"required_secrets"` Inject AuthInjectSpec `json:"inject"` } type EncryptedRecord struct { Encrypted string `json:"encrypted"` IV string `json:"iv"` } type TokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token,omitempty"` Error string `json:"error"` ErrorDesc string `json:"error_description"` } // ── main ───────────────────────────────────────────────────────────────────── func main() { raw, err := io.ReadAll(os.Stdin) if err != nil { writeError("failed to read stdin: " + err.Error()) return } var input Input if err := json.Unmarshal(raw, &input); err != nil { writeError("invalid input JSON: " + err.Error()) return } if input.APIKey == "" { writeError("api_key 必填") return } if input.Service == "" { writeError("service 必填") return } action := input.Action if action == "" { action = "authenticate" } // 讀 auth recipe recipeJSON, status := kvGet("auth_recipe:" + input.Service) if status == 2 { writeError("找不到 auth recipe: " + input.Service) return } if status != 0 { writeError("kv_get 失敗(auth_recipe)") return } var recipe AuthRecipe if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil { writeError("auth recipe JSON 解析失敗: " + err.Error()) return } if recipe.Primitive != "oauth2" { writeError("auth recipe " + input.Service + " 的 primitive 不是 oauth2(是 " + recipe.Primitive + ")") return } if recipe.OAuth2 == nil || recipe.OAuth2.TokenEndpoint == "" { writeError("auth recipe " + input.Service + " 缺少 oauth2.token_endpoint") return } switch action { case "needs_refresh": handleNeedsRefresh(input, recipe) case "refresh": handleRefresh(input, recipe) case "authenticate": handleAuthenticate(input, recipe) default: writeError("不支援的 action: " + action) } } // ── action handlers ─────────────────────────────────────────────────────────── func handleNeedsRefresh(input Input, recipe AuthRecipe) { // 讀 cached expires_at(若無,視為需要 refresh) expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at" expiresStr, status := kvGet(expiresKey) if status != 0 { // 找不到 = 需要 refresh out, _ := json.Marshal(map[string]interface{}{ "success": true, "needs_refresh": true, }) os.Stdout.Write(out) return } expiresAt, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64) if err != nil { out, _ := json.Marshal(map[string]interface{}{ "success": true, "needs_refresh": true, }) os.Stdout.Write(out) return } // 提前 5 分鐘視為需要 refresh needsRefresh := time.Now().Unix()+300 >= expiresAt out, _ := json.Marshal(map[string]interface{}{ "success": true, "needs_refresh": needsRefresh, }) os.Stdout.Write(out) } func handleRefresh(input Input, recipe AuthRecipe) { accessToken, expiresAt, ok := doRefresh(input, recipe) if !ok { return } // 快取新 token cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt) out, _ := json.Marshal(map[string]interface{}{ "success": true, "runtime": map[string]string{"access_token": accessToken}, "auth_headers": map[string]string{}, "auth_query": map[string]string{}, "auth_body": map[string]string{}, }) os.Stdout.Write(out) } func handleAuthenticate(input Input, recipe AuthRecipe) { // 先嘗試讀 cached access_token cachedKey := input.APIKey + ":oauth2:" + input.Service + ":access_token" expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at" cachedToken, cStatus := kvGet(cachedKey) expiresStr, eStatus := kvGet(expiresKey) var accessToken string var expiresAt int64 useCache := false if cStatus == 0 && eStatus == 0 && cachedToken != "" { if exp, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64); err == nil { if time.Now().Unix()+300 < exp { accessToken = cachedToken expiresAt = exp useCache = true } } } if !useCache { var ok bool accessToken, expiresAt, ok = doRefresh(input, recipe) if !ok { return } cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt) } runtime := map[string]string{"access_token": accessToken} secrets := map[string]string{} // oauth2 inject 只用 runtime.*,不用 secret.* authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime) authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime) authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime) out, _ := json.Marshal(map[string]interface{}{ "success": true, "auth_headers": authHeaders, "auth_query": authQuery, "auth_body": authBody, "runtime": runtime, }) os.Stdout.Write(out) } // doRefresh 解密 refresh_token,打 token endpoint,回傳 (access_token, expires_at_unix, ok) func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) { if len(recipe.RequiredSecrets) == 0 { writeError("auth recipe " + input.Service + " 缺少 required_secrets(需要 refresh_token)") return "", 0, false } // 慣例:required_secrets[0] 是 refresh_token rtReq := recipe.RequiredSecrets[0] kvKey := input.APIKey + ":cred:" + rtReq.Key encJSON, s := kvGet(kvKey) if s == 2 { writeError("缺少 credential: " + rtReq.Key + "(" + rtReq.Label + ")。執行 acr creds push 推送") return "", 0, false } if s != 0 { writeError("kv_get 失敗(credential " + rtReq.Key + ")") return "", 0, false } var rec EncryptedRecord if err := json.Unmarshal([]byte(encJSON), &rec); err != nil { writeError("credential " + rtReq.Key + " 格式錯誤: " + err.Error()) return "", 0, false } refreshToken, ok := cryptoDecrypt(rec.Encrypted, rec.IV) if !ok { writeError("credential " + rtReq.Key + " 解密失敗") return "", 0, false } // POST token endpoint(form-urlencoded) cfg := recipe.OAuth2 form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", refreshToken) form.Set("client_id", cfg.ClientID) form.Set("client_secret", cfg.ClientSecret) formBody := form.Encode() headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}` respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody) if !ok2 { writeError("token endpoint HTTP 請求失敗") return "", 0, false } var tokenResp TokenResponse if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil { writeError("token endpoint 回應解析失敗: " + err.Error()) return "", 0, false } if tokenResp.AccessToken == "" { msg := tokenResp.Error if tokenResp.ErrorDesc != "" { msg += ": " + tokenResp.ErrorDesc } if msg == "" { msg = "access_token 為空" } writeError("token exchange 失敗: " + msg) return "", 0, false } expiresIn := tokenResp.ExpiresIn if expiresIn <= 0 { expiresIn = 3600 } expiresAt := time.Now().Unix() + int64(expiresIn) // 若 token endpoint 回傳新的 refresh_token(部分服務會 rotate),更新快取 // 注意:寫回加密 KV 需要 host 支援;此處只快取 access_token(明文短效) // refresh_token 的 rotation 需要 kv_put_encrypted host function(未來擴充) return tokenResp.AccessToken, expiresAt, true } func cacheAccessToken(apiKey, service, accessToken string, expiresAt int64) { // 快取明文 access_token(短效,TTL = expires_at - now + 60s buffer) ttl := uint32(expiresAt - time.Now().Unix() + 60) if ttl > 7200 { ttl = 7200 } cachedKey := apiKey + ":oauth2:" + service + ":access_token" expiresKey := apiKey + ":oauth2:" + service + ":expires_at" expiresStr := strconv.FormatInt(expiresAt, 10) kvPut(cachedKey, accessToken, ttl) kvPut(expiresKey, expiresStr, ttl) } // ── helpers ─────────────────────────────────────────────────────────────────── func writeError(msg string) { out, _ := json.Marshal(map[string]interface{}{ "success": false, "error": msg, "auth_headers": map[string]string{}, "auth_query": map[string]string{}, "auth_body": map[string]string{}, }) os.Stdout.Write(out) } func kvGet(key string) (string, uint32) { keyBytes := []byte(key) outBuf := make([]byte, 65536) var outLen uint32 status := hostKvGet( uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)), uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) if status != 0 { return "", status } return string(outBuf[:outLen]), 0 } func kvPut(key, value string, ttlSeconds uint32) { keyBytes := []byte(key) valBytes := []byte(value) if len(keyBytes) == 0 || len(valBytes) == 0 { return } hostKvPut( uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)), uintptr(unsafe.Pointer(&valBytes[0])), uint32(len(valBytes)), ttlSeconds, ) } func cryptoDecrypt(encB64, ivB64 string) (string, bool) { encBytes := []byte(encB64) ivBytes := []byte(ivB64) if len(encBytes) == 0 || len(ivBytes) == 0 { return "", false } outBuf := make([]byte, 65536) var outLen uint32 status := hostCryptoDecrypt( uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)), uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)), uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) if status != 0 { return "", false } return string(outBuf[:outLen]), true } func httpRequest(reqURL, method, headersJSON, body string) (string, bool) { urlBytes := []byte(reqURL) methodBytes := []byte(method) headersBytes := []byte(headersJSON) bodyBytes := []byte(body) if len(urlBytes) == 0 { return "", false } outBuf := make([]byte, 65536) var outLen uint32 var bodyPtr uintptr if len(bodyBytes) > 0 { bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0])) } var headersPtr uintptr if len(headersBytes) > 0 { headersPtr = uintptr(unsafe.Pointer(&headersBytes[0])) } status := hostHttpRequest( uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)), uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)), headersPtr, uint32(len(headersBytes)), bodyPtr, uint32(len(bodyBytes)), uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) if status != 0 { return "", false } return string(outBuf[:outLen]), true } func interpolateTemplate(template string, secrets, runtime map[string]string) string { var b strings.Builder b.Grow(len(template)) i := 0 for i < len(template) { start := strings.Index(template[i:], "{{") if start < 0 { b.WriteString(template[i:]) break } b.WriteString(template[i : i+start]) openIdx := i + start closeRel := strings.Index(template[openIdx+2:], "}}") if closeRel < 0 { b.WriteString(template[openIdx:]) break } inner := template[openIdx+2 : openIdx+2+closeRel] advance := openIdx + 2 + closeRel + 2 switch { case strings.HasPrefix(inner, "secret."): key := inner[len("secret."):] b.WriteString(secrets[key]) case strings.HasPrefix(inner, "runtime."): key := inner[len("runtime."):] b.WriteString(runtime[key]) default: b.WriteString(template[openIdx:advance]) } i = advance } return b.String() } func interpolateRecord(record map[string]string, secrets, runtime map[string]string) map[string]string { if record == nil { return map[string]string{} } result := make(map[string]string, len(record)) for k, v := range record { result[k] = interpolateTemplate(v, secrets, runtime) } return result }