yao/openapi/oauth/token.go
Max c6e1c449e1 Enhance Tai service readiness checks and OAuth device flow
- Improve health check logic in CI workflows for both HTTP and gRPC readiness of the Tai service, ensuring clearer error reporting if the service fails to start.
- Update the OAuth Device Flow implementation to support additional claims during device authorization, enhancing the flexibility of the authorization process.
- Refactor the `AuthorizeDevice` method to accept extra claims, allowing for more detailed user context during authorization.
- Introduce a new utility function to extract bearer tokens from requests, streamlining token handling across the OpenAPI service.

These changes enhance the robustness of service readiness checks and improve the OAuth device authorization flow, contributing to a more reliable and flexible authentication mechanism.
2026-03-04 16:55:29 +08:00

817 lines
26 KiB
Go

package oauth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/yaoapp/yao/openapi/oauth/types"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// Introspect returns information about an access token
// This endpoint allows resource servers to validate tokens
func (s *Service) Introspect(ctx context.Context, token string) (*types.TokenIntrospectionResponse, error) {
// Try to verify token using signature verification first
tokenClaims, err := s.VerifyToken(token)
if err != nil {
// If signature verification fails, try to get from store (for opaque tokens)
return s.introspectFromStore(token)
}
// Token is valid, build response from verified claims
response := &types.TokenIntrospectionResponse{
Active: true,
ClientID: tokenClaims.ClientID,
Subject: tokenClaims.Subject,
Scope: tokenClaims.Scope,
TokenType: "Bearer",
ExpiresAt: tokenClaims.ExpiresAt.Unix(),
IssuedAt: tokenClaims.IssuedAt.Unix(),
}
// Check if token is expired
if !tokenClaims.ExpiresAt.IsZero() && time.Now().After(tokenClaims.ExpiresAt) {
response.Active = false
}
return response, nil
}
// introspectFromStore fallback method for token introspection from store
func (s *Service) introspectFromStore(token string) (*types.TokenIntrospectionResponse, error) {
// Try to get token data from OAuth store
tokenInfo, err := s.getAccessTokenData(token)
if err != nil {
return &types.TokenIntrospectionResponse{Active: false}, nil
}
// Check if token exists and is valid
if tokenInfo == nil {
return &types.TokenIntrospectionResponse{Active: false}, nil
}
// Extract token information
response := &types.TokenIntrospectionResponse{
Active: true,
}
// Extract standard fields from token data
if clientID, ok := tokenInfo["client_id"].(string); ok {
response.ClientID = clientID
}
if subject, ok := tokenInfo["subject"].(string); ok {
response.Subject = subject
}
if tokenType, ok := tokenInfo["token_type"].(string); ok {
response.TokenType = tokenType
} else {
response.TokenType = "Bearer"
}
if scope, ok := tokenInfo["scope"].(string); ok {
response.Scope = scope
}
if exp, ok := tokenInfo["expires_at"].(int64); ok {
response.ExpiresAt = exp
}
if iat, ok := tokenInfo["issued_at"].(int64); ok {
response.IssuedAt = iat
}
// Check if token is expired
if response.ExpiresAt > 0 && time.Now().Unix() > response.ExpiresAt {
response.Active = false
}
return response, nil
}
// TokenExchange exchanges one token for another token
// This implements RFC 8693 for token exchange scenarios
func (s *Service) TokenExchange(ctx context.Context, subjectToken string, subjectTokenType string, audience string, scope string) (*types.TokenExchangeResponse, error) {
// Check if token exchange is enabled
if !s.config.Features.TokenExchangeEnabled {
return nil, &types.ErrorResponse{
Code: types.ErrorUnsupportedGrantType,
ErrorDescription: "Token exchange is not enabled",
}
}
// Validate subject token
introspectionResult, err := s.Introspect(ctx, subjectToken)
if err != nil {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid subject token",
}
}
if !introspectionResult.Active {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Subject token is not active",
}
}
// Validate audience if provided
if audience != "" {
if err := s.validateAudience(audience); err != nil {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidRequest,
ErrorDescription: "Invalid audience",
}
}
}
// Validate scope if provided
if scope != "" {
scopes := strings.Fields(scope)
if introspectionResult.ClientID != "" {
scopeValidation, err := s.clientProvider.ValidateScope(ctx, introspectionResult.ClientID, scopes)
if err != nil {
return nil, err
}
if !scopeValidation.Valid {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidScope,
ErrorDescription: "Invalid scope",
}
}
}
}
// Generate new token for exchange
newToken, err := s.generateExchangedToken(subjectToken, audience)
if err != nil {
return nil, &types.ErrorResponse{
Code: types.ErrorServerError,
ErrorDescription: "Failed to generate exchanged token",
}
}
response := &types.TokenExchangeResponse{
AccessToken: newToken,
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
TokenType: "Bearer",
ExpiresIn: int(s.config.Token.AccessTokenLifetime.Seconds()),
}
if scope != "" {
response.Scope = scope
}
return response, nil
}
// ValidateTokenAudience validates token audience claims
// This ensures tokens are only used with their intended audiences
func (s *Service) ValidateTokenAudience(ctx context.Context, token string, expectedAudience string) (*types.ValidationResult, error) {
result := &types.ValidationResult{Valid: false}
// Get token introspection
introspectionResult, err := s.Introspect(ctx, token)
if err != nil {
return nil, err
}
if !introspectionResult.Active {
result.Errors = append(result.Errors, "Token is not active")
return result, nil
}
// Check audience
if len(introspectionResult.Audience) == 0 {
// If no audience is specified in token, allow access
result.Valid = true
return result, nil
}
// Check if expected audience is in token audience list
for _, aud := range introspectionResult.Audience {
if aud == expectedAudience {
result.Valid = true
return result, nil
}
}
result.Errors = append(result.Errors, "Token audience does not match expected audience")
return result, nil
}
// ValidateTokenBinding validates token binding information
// This ensures tokens are bound to the correct client or device
func (s *Service) ValidateTokenBinding(ctx context.Context, token string, binding *types.TokenBinding) (*types.ValidationResult, error) {
result := &types.ValidationResult{Valid: false}
// Check if token binding is enabled
if !s.config.Features.TokenBindingEnabled {
result.Valid = true // If not enabled, always valid
return result, nil
}
// Get token introspection
introspectionResult, err := s.Introspect(ctx, token)
if err != nil {
return nil, err
}
if !introspectionResult.Active {
result.Errors = append(result.Errors, "Token is not active")
return result, nil
}
// Validate binding type
switch binding.BindingType {
case types.TokenBindingTypeDPoP:
// DPoP binding validation would go here
result.Valid = true // Placeholder
case types.TokenBindingTypeMTLS:
// mTLS binding validation would go here
result.Valid = true // Placeholder
case types.TokenBindingTypeCertificate:
// Certificate binding validation would go here
result.Valid = true // Placeholder
default:
result.Errors = append(result.Errors, "Unknown token binding type")
return result, nil
}
return result, nil
}
// ============================================================================
// Public Token helper methods for internal use
// ============================================================================
// MakeAccessToken generates a new access token with specific parameters and stores it
// extraClaims: optional extra claims to add to the token (e.g., team_id, tenant_id)
func (s *Service) MakeAccessToken(clientID, scope, subject string, expiresIn int, extraClaims ...map[string]interface{}) (string, error) {
var claims map[string]interface{}
if len(extraClaims) > 0 {
claims = extraClaims[0]
}
return s.generateAccessTokenWithScope(clientID, scope, subject, expiresIn, claims)
}
// MakeRefreshToken generates a new refresh token with specific parameters and stores it
func (s *Service) MakeRefreshToken(clientID, scope, subject string, expiresIn int, extraClaims ...map[string]interface{}) (string, error) {
var claims map[string]interface{}
if len(extraClaims) > 0 {
claims = extraClaims[0]
}
return s.generateRefreshToken(clientID, scope, subject, expiresIn, claims)
}
// Subject converts a userID to a subject using NanoID fingerprint
func (s *Service) Subject(clientID, userID string) (string, error) {
// Check if mapping already exists for this clientID+userID
mappingKey := s.userMappingKey(clientID, userID)
if existingNanoID, exists := s.store.Get(mappingKey); exists {
if nanoIDStr, ok := existingNanoID.(string); ok {
return nanoIDStr, nil
}
}
maxRetries := 5
for i := 0; i < maxRetries; i++ {
// Generate 16-character NanoID
nanoID, err := generateNumericID(16)
if err != nil {
return "", fmt.Errorf("failed to generate NanoID: %w", err)
}
// Check if this NanoID already exists for this client
key := s.userFingerprintKey(clientID, nanoID)
_, exists := s.store.Get(key)
if !exists {
// Store both mappings
// 1. clientID:nanoID -> userID
if err := s.store.Set(key, userID, 0); err != nil {
return "", fmt.Errorf("failed to store user fingerprint: %w", err)
}
// 2. clientID:userID -> nanoID (for checking existing mapping)
if err := s.store.Set(mappingKey, nanoID, 0); err != nil {
return "", fmt.Errorf("failed to store user mapping: %w", err)
}
return nanoID, nil
}
}
return "", fmt.Errorf("failed to generate unique NanoID after %d retries", maxRetries)
}
// UserID converts a subject to a userID using fingerprint lookup
func (s *Service) UserID(clientID, subject string) (string, error) {
key := s.userFingerprintKey(clientID, subject)
userID, exists := s.store.Get(key)
if !exists {
return "", fmt.Errorf("fingerprint not found")
}
userIDStr, ok := userID.(string)
if !ok {
return "", fmt.Errorf("invalid userID format")
}
return userIDStr, nil
}
// MakeAuthorizationCode generates a new authorization code with specific parameters and stores it
// ============================================================================
// Helper methods
// ============================================================================
// validateAudience validates if an audience is valid
func (s *Service) validateAudience(audience string) error {
// Basic audience validation
if audience == "" {
return &types.ErrorResponse{
Code: types.ErrorInvalidRequest,
ErrorDescription: "Audience cannot be empty",
}
}
// Add more sophisticated audience validation here
// For example, checking against a whitelist of valid audiences
return nil
}
// Token generation helper methods
// generateAccessToken generates a new access token
func (s *Service) generateAccessToken(clientID string) (string, error) {
expiresIn := int(s.config.Token.AccessTokenLifetime.Seconds())
return s.generateAccessTokenWithScope(clientID, "", "", expiresIn, nil)
}
// generateAccessTokenWithScope generates a new access token with specific parameters and stores it
func (s *Service) generateAccessTokenWithScope(clientID, scope, subject string, expiresIn int, extraClaims map[string]interface{}) (string, error) {
// Use the new signing mechanism based on configuration
var accessToken string
var err error
if extraClaims != nil {
accessToken, err = s.SignToken("access_token", clientID, scope, subject, expiresIn, extraClaims)
} else {
accessToken, err = s.SignToken("access_token", clientID, scope, subject, expiresIn)
}
if err != nil {
return "", err
}
// Store access token with metadata (including extra claims)
err = s.storeAccessToken(accessToken, clientID, scope, subject, expiresIn, extraClaims)
if err != nil {
return "", err
}
return accessToken, nil
}
// storeAccessToken stores access token with metadata and specified expiration
func (s *Service) storeAccessToken(accessToken, clientID string, scope string, subject string, expiresIn int, extraClaims map[string]interface{}) error {
now := time.Now()
expiresAt := now.Add(time.Duration(expiresIn) * time.Second).Unix()
tokenData := map[string]interface{}{
"client_id": clientID,
"type": "access_token",
"scope": scope,
"subject": subject,
"token_type": "Bearer",
"issued_at": now.Unix(),
"expires_at": expiresAt,
}
// Add extra claims if provided (e.g., team_id, tenant_id)
for key, value := range extraClaims {
tokenData[key] = value
}
ttl := time.Duration(expiresIn) * time.Second
return s.store.Set(s.accessTokenKey(accessToken), tokenData, ttl)
}
// getAccessTokenData retrieves access token data
func (s *Service) getAccessTokenData(accessToken string) (map[string]interface{}, error) {
tokenData, exists := s.store.Get(s.accessTokenKey(accessToken))
if !exists {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidToken,
ErrorDescription: "Invalid access token",
}
}
// Convert to map[string]interface{} if needed
tokenInfo, ok := tokenData.(map[string]interface{})
if !ok {
// Try primitive.M for MongoDB store compatibility
if primitiveM, isPrimitiveM := tokenData.(primitive.M); isPrimitiveM {
// Convert primitive.M to map[string]interface{}
tokenInfo = make(map[string]interface{})
for k, v := range primitiveM {
tokenInfo[k] = v
}
} else {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidToken,
ErrorDescription: "Invalid token format",
}
}
}
return tokenInfo, nil
}
// revokeAccessToken deletes access token from store
func (s *Service) revokeAccessToken(accessToken string) error {
s.store.Del(s.accessTokenKey(accessToken))
return nil
}
// generateRefreshToken generates and stores a new refresh token with scope and subject
func (s *Service) generateRefreshToken(clientID, scope, subject string, expiresIn int, extraClaims map[string]interface{}) (string, error) {
refreshToken, err := s.generateToken("rfk", clientID)
if err != nil {
return "", err
}
// Store refresh token with metadata
err = s.storeRefreshTokenWithScope(refreshToken, clientID, scope, subject, expiresIn, extraClaims)
if err != nil {
return "", err
}
return refreshToken, nil
}
// generateAuthorizationCodeWithInfo generates a new authorization code with authorization information
func (s *Service) generateAuthorizationCodeWithInfo(clientID, state, scope, codeChallenge, codeChallengeMethod string, subject ...string) (string, error) {
authCode, err := s.generateToken("ac", clientID)
if err != nil {
return "", err
}
// Store authorization code with metadata for later validation
err = s.storeAuthorizationCode(authCode, clientID, state, scope, codeChallenge, codeChallengeMethod, subject...)
if err != nil {
return "", fmt.Errorf("failed to store authorization code: %w", err)
}
return authCode, nil
}
// storeAuthorizationCode stores authorization code with metadata
func (s *Service) storeAuthorizationCode(code, clientID, state, scope, codeChallenge, codeChallengeMethod string, subject ...string) error {
codeData := map[string]interface{}{
"client_id": clientID,
"state": state,
"type": "authorization_code",
"issued_at": time.Now().Unix(),
"expires_at": time.Now().Add(s.config.Token.AuthorizationCodeLifetime).Unix(),
}
// Add scope if provided
if scope != "" {
codeData["scope"] = scope
}
// Add subject if provided (optional parameter)
if len(subject) > 0 && subject[0] != "" {
codeData["subject"] = subject[0]
}
// Add PKCE information if provided
if codeChallenge != "" {
codeData["code_challenge"] = codeChallenge
if codeChallengeMethod != "" {
codeData["code_challenge_method"] = codeChallengeMethod
} else {
// Default to S256 if not specified
codeData["code_challenge_method"] = types.CodeChallengeMethodS256
}
}
return s.store.Set(s.authorizationCodeKey(code), codeData, s.config.Token.AuthorizationCodeLifetime)
}
// getAuthorizationCodeData retrieves and validates authorization code data
func (s *Service) getAuthorizationCodeData(code string) (map[string]interface{}, error) {
codeData, exists := s.store.Get(s.authorizationCodeKey(code))
if !exists {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid or expired authorization code",
}
}
// Convert to map[string]interface{} if needed
codeInfo, ok := codeData.(map[string]interface{})
if !ok {
// Try primitive.M for MongoDB store compatibility
if primitiveM, isPrimitiveM := codeData.(primitive.M); isPrimitiveM {
// Convert primitive.M to map[string]interface{}
codeInfo = make(map[string]interface{})
for k, v := range primitiveM {
codeInfo[k] = v
}
} else {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid authorization code format",
}
}
}
return codeInfo, nil
}
// consumeAuthorizationCode retrieves and deletes authorization code (prevents reuse)
func (s *Service) consumeAuthorizationCode(code string) error {
s.store.Del(s.authorizationCodeKey(code))
return nil
}
// deviceCodeKey generates a key for device code storage
func (s *Service) deviceCodeKey(code string) string {
return fmt.Sprintf("%soauth:device_code:%s", s.prefix, code)
}
// userCodeKey generates a key for user code storage (reverse mapping)
func (s *Service) userCodeKey(code string) string {
return fmt.Sprintf("%soauth:user_code:%s", s.prefix, code)
}
// storeDeviceCode stores device code data and user_code -> device_code reverse mapping
func (s *Service) storeDeviceCode(deviceCode, userCode, clientID, scope string) error {
ttl := s.config.Token.DeviceCodeLifetime
codeData := map[string]interface{}{
"client_id": clientID,
"user_code": userCode,
"scope": scope,
"status": "pending",
"issued_at": time.Now().Unix(),
"expires_at": time.Now().Add(ttl).Unix(),
}
if err := s.store.Set(s.deviceCodeKey(deviceCode), codeData, ttl); err != nil {
return err
}
reverseData := map[string]interface{}{
"device_code": deviceCode,
}
return s.store.Set(s.userCodeKey(userCode), reverseData, ttl)
}
// getDeviceCodeData retrieves device code data from store
func (s *Service) getDeviceCodeData(deviceCode string) (map[string]interface{}, error) {
data, exists := s.store.Get(s.deviceCodeKey(deviceCode))
if !exists {
return nil, &types.ErrorResponse{
Code: types.ErrorExpiredToken,
ErrorDescription: "Device code not found or expired",
}
}
codeInfo, ok := data.(map[string]interface{})
if !ok {
if m, ok := data.(primitive.M); ok {
codeInfo = map[string]interface{}(m)
} else {
return nil, &types.ErrorResponse{
Code: types.ErrorServerError,
ErrorDescription: "Invalid device code data format",
}
}
}
return codeInfo, nil
}
// authorizeDeviceCode marks a device code as authorized via user_code lookup
func (s *Service) authorizeDeviceCode(userCode, subject string, extraClaims map[string]interface{}) error {
reverseData, exists := s.store.Get(s.userCodeKey(userCode))
if !exists {
return &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid or expired user code",
}
}
var deviceCode string
switch v := reverseData.(type) {
case map[string]interface{}:
deviceCode, _ = v["device_code"].(string)
case primitive.M:
deviceCode, _ = v["device_code"].(string)
}
if deviceCode == "" {
return &types.ErrorResponse{
Code: types.ErrorServerError,
ErrorDescription: "Invalid user code mapping",
}
}
codeData, err := s.getDeviceCodeData(deviceCode)
if err != nil {
return err
}
codeData["status"] = "authorized"
codeData["subject"] = subject
if extraClaims != nil {
codeData["extra_claims"] = extraClaims
}
// Re-store with remaining TTL
expiresAt, _ := codeData["expires_at"].(int64)
if expiresAt == 0 {
if f, ok := codeData["expires_at"].(float64); ok {
expiresAt = int64(f)
}
}
remaining := time.Until(time.Unix(expiresAt, 0))
if remaining <= 0 {
return &types.ErrorResponse{
Code: types.ErrorExpiredToken,
ErrorDescription: "Device code has expired",
}
}
return s.store.Set(s.deviceCodeKey(deviceCode), codeData, remaining)
}
// consumeDeviceCode deletes both device_code and user_code entries
func (s *Service) consumeDeviceCode(deviceCode string) error {
codeData, _ := s.getDeviceCodeData(deviceCode)
if codeData != nil {
if uc, ok := codeData["user_code"].(string); ok && uc != "" {
s.store.Del(s.userCodeKey(uc))
}
}
s.store.Del(s.deviceCodeKey(deviceCode))
return nil
}
// storeRefreshToken stores refresh token with metadata
func (s *Service) storeRefreshToken(refreshToken, clientID string) error {
tokenData := map[string]interface{}{
"client_id": clientID,
"type": "refresh_token",
"issued_at": time.Now().Unix(),
}
return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, s.config.Token.RefreshTokenLifetime)
}
// storeRefreshTokenWithScope stores refresh token with metadata including scope and subject
func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subject string, expiresIn int, extraClaims map[string]interface{}) error {
tokenData := map[string]interface{}{
"client_id": clientID,
"scope": scope,
"subject": subject,
"type": "refresh_token",
"issued_at": time.Now().Unix(),
}
// Add extra claims if provided (e.g., team_id, tenant_id)
for key, value := range extraClaims {
tokenData[key] = value
}
expires := s.config.Token.RefreshTokenLifetime
if expiresIn > 0 {
expires = time.Duration(expiresIn) * time.Second
}
return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, expires)
}
// getRefreshTokenData retrieves refresh token data
func (s *Service) getRefreshTokenData(refreshToken string) (map[string]interface{}, error) {
tokenData, exists := s.store.Get(s.refreshTokenKey(refreshToken))
if !exists {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid refresh token",
}
}
// Convert to map[string]interface{} if needed
tokenInfo, ok := tokenData.(map[string]interface{})
if !ok {
// Try primitive.M for MongoDB store compatibility
if primitiveM, isPrimitiveM := tokenData.(primitive.M); isPrimitiveM {
// Convert primitive.M to map[string]interface{}
tokenInfo = make(map[string]interface{})
for k, v := range primitiveM {
tokenInfo[k] = v
}
} else {
return nil, &types.ErrorResponse{
Code: types.ErrorInvalidGrant,
ErrorDescription: "Invalid token format",
}
}
}
return tokenInfo, nil
}
// revokeRefreshToken deletes refresh token from store
func (s *Service) revokeRefreshToken(refreshToken string) error {
s.store.Del(s.refreshTokenKey(refreshToken))
return nil
}
// authorizationCodeKey generates a key for authorization code storage
func (s *Service) authorizationCodeKey(code string) string {
return fmt.Sprintf("%soauth:auth_code:%s", s.prefix, code)
}
// refreshTokenKey generates a key for refresh token storage
func (s *Service) refreshTokenKey(refreshToken string) string {
return fmt.Sprintf("%soauth:refresh_token:%s", s.prefix, refreshToken)
}
// accessTokenKey generates a key for access token storage
func (s *Service) accessTokenKey(accessToken string) string {
return fmt.Sprintf("%soauth:access_token:%s", s.prefix, accessToken)
}
// userFingerprintKey generates a key for user fingerprint storage
func (s *Service) userFingerprintKey(clientID, nanoID string) string {
return fmt.Sprintf("%soauth:user_fingerprint:%s:%s", s.prefix, clientID, nanoID)
}
// userMappingKey generates a key for reverse user mapping (clientID+userID -> nanoID)
func (s *Service) userMappingKey(clientID, userID string) string {
return fmt.Sprintf("%soauth:user_mapping:%s:%s", s.prefix, clientID, userID)
}
// generateExchangedToken generates a new token for token exchange
func (s *Service) generateExchangedToken(subjectToken string, audience string) (string, error) {
// Extract token prefix for tracking purposes
tokenPrefix := subjectToken
if len(subjectToken) > 20 {
tokenPrefix = subjectToken[:20]
}
// Generate a more secure token using the same pattern as other tokens
// For now, we'll use a simple concatenation approach
// In a real implementation, this would generate a JWT or opaque token
exchangedToken := "exchanged_" + tokenPrefix + "_" + audience
return exchangedToken, nil
}
// generateToken generates a token with the specified type and client ID
func (s *Service) generateToken(tokenType string, clientID string) (string, error) {
// Generate random bytes for token
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Create token with type, client ID, timestamp, and random component
randomPart := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(randomBytes)
// Replace underscores with hyphens to avoid conflicts with our delimiter
randomPart = strings.ReplaceAll(randomPart, "_", "-")
// Remove any spaces or newlines that might be in the base64 encoding
randomPart = strings.ReplaceAll(randomPart, " ", "")
randomPart = strings.ReplaceAll(randomPart, "\n", "")
randomPart = strings.ReplaceAll(randomPart, "\t", "")
timestamp := time.Now().Format("20060102150405")
return fmt.Sprintf("%s_%s_%s_%s", tokenType, clientID, timestamp, randomPart), nil
}
// ============================================================================
// User Fingerprint Methods
// ============================================================================
// generateNumericID generates a deterministic numeric ID using simple hash mapping
func generateNumericID(length int) (string, error) {
if length <= 0 || length > 16 {
return "", fmt.Errorf("length must be between 1 and 16")
}
// Use only digits 0-9 for numeric ID
// This provides 10^length possible combinations
// For 16 digits, that's 10^16 = 10,000,000,000,000,000 possibilities
const numericAlphabet = "0123456789"
return gonanoid.Generate(numericAlphabet, length)
}
// DeleteUserFingerprint removes a fingerprint mapping
func (s *Service) DeleteUserFingerprint(clientID, nanoID string) error {
key := s.userFingerprintKey(clientID, nanoID)
s.store.Del(key)
return nil
}