다음을 통해 공유


원격 엔드포인트 인증

Microsoft Fabric 워크로드의 원격 엔드포인트는 사용자 컨텍스트와 애플리케이션 ID를 모두 제공하는 특수 인증 메커니즘을 사용합니다. 이 문서에서는 작업, 수명 주기 알림 및 기타 백 엔드 작업을 처리하는 원격 엔드포인트에 대한 인증을 구현하는 방법을 설명합니다.

인증 흐름 개요

패브릭 워크로드에는 다음 세 가지 기본 인증 흐름이 포함됩니다.

  1. Fabric에서 워크로드로 - Fabric은 SubjectAndAppToken 형식으로 원격 엔드포인트를 호출합니다.
  2. Fabric에 대한 워크로드 - 워크로드가 SubjectAndAppToken 또는 전달자 토큰을 사용하여 Fabric API를 호출합니다.
  3. 워크로드 프런트 엔드에서 백 엔드 로 - 프런트 엔드가 전달자 토큰을 사용하여 백 엔드를 호출합니다.

이 문서에서는 Fabric에서 워크로드로 요청을 인증하는 방법과 인증된 호출을 Fabric으로 다시 수행하는 방법에 중점을 둡니다.

SubjectAndAppToken 형식

Fabric은 작업, 수명 주기 알림 또는 기타 작업의 경우 원격 엔드포인트를 호출할 때 SubjectAndAppToken이라는 이중 토큰 인증 형식을 사용합니다.

권한 부여 헤더 구조

SubjectAndAppToken1.0 subjectToken="<user-delegated-token>", appToken="<app-only-token>"

이 형식에는 다음과 같은 두 가지 고유 토큰이 포함됩니다.

  • subjectToken - 작업을 대신하는 사용자를 나타내는 위임된 토큰
  • appToken - 패브릭 애플리케이션의 앱 전용 토큰으로, 패브릭에서 시작된 요청을 증명합니다.

중요합니다

항상 subjectToken 있는 것은 아닙니다. 다음과 같은 시나리오에서 누락될 수 있습니다.

  • 서비스 주체 작업 - 서비스 주체가 사용자 컨텍스트 없이 Fabric 공용 API와 상호 작용하는 경우
  • 시스템 작업 - 사용자가 직접 관여하지 않는 항목 삭제와 같은 작업
  • 자동화된 워크플로 - CI/CD 파이프라인 또는 사용자 상호 작용 없이 실행되는 예약된 작업

원격 엔드포인트는 사용자 컨텍스트가 있는 요청(subjectToken이 있음) 및 사용자 컨텍스트가 없는 요청(subjectToken 없음)의 두 경우를 모두 처리하도록 설계되어야 합니다.

이중 토큰을 사용하는 이유는 무엇인가요?

이중 토큰 접근 방식은 다음 세 가지 주요 이점을 제공합니다.

  1. 유효성 검사 - appToken의 유효성을 검사하여 요청이 패브릭에서 시작되었는지 확인합니다.
  2. 사용자 컨텍스트 - subjectToken은 수행 중인 작업에 대한 사용자 컨텍스트를 제공합니다.
  3. Inter-Service 통신 - subjectToken을 사용하여 OBO(On-Behalf-Of) 토큰을 획득하고, 이를 통해 사용자 컨텍스트로 다른 서비스를 호출합니다.

SubjectAndAppToken 구문 분석

원격 엔드포인트는 권한 부여 헤더를 구문 분석하여 두 토큰을 모두 추출해야 합니다.

JavaScript 예제

/**
 * Parse SubjectAndAppToken from Authorization header
 * @param {string} authHeader - Authorization header value
 * @returns {object|null} Parsed tokens or null if invalid format
 */
function parseSubjectAndAppToken(authHeader) {
  if (!authHeader || !authHeader.startsWith('SubjectAndAppToken1.0 ')) {
    return null;
  }

  const tokenPart = authHeader.substring('SubjectAndAppToken1.0 '.length);
  const tokens = {};
  
  const parts = tokenPart.split(',');

  for (const part of parts) {
    const [key, value] = part.split('=');
    if (key && value) {
      // Remove surrounding quotes from the token value
      const cleanValue = value.trim().replace(/^"(.*)"$/, '$1');
      tokens[key.trim()] = cleanValue;
    }
  }

  return {
    subjectToken: tokens.subjectToken || null,
    appToken: tokens.appToken || null
  };
}

사용 예제

const authHeader = req.headers['authorization'];
const tokens = parseSubjectAndAppToken(authHeader);

if (!tokens || !tokens.appToken) {
  return res.status(401).json({ error: 'Invalid authorization header' });
}

// Now you have access to:
// - tokens.subjectToken (user-delegated token, may be null)
// - tokens.appToken (Fabric app token, always present)

// Check if user context is available
if (tokens.subjectToken) {
  console.log('Request has user context');
} else {
  console.log('Request is app-only (no user context)');
}

토큰 유효성 검사

신뢰성과 적절한 클레임을 보장하기 위해 두 토큰의 유효성을 검사해야 합니다.

앱 토큰 유효성 검사

패브릭 appToken 의 앱 전용 토큰입니다. 유효성 검사:

  • 토큰 서명 - Azure AD 서명 키에 대해 확인
  • 토큰 수명 - (이전이 아님) 및 nbf (만료) 클레임 확인 exp
  • 대상 그룹 - 워크로드의 앱 등록과 일치해야 합니다.
  • Issuer - 올바른 테넌트가 있는 Azure AD에서 온 것이어야 합니다.
  • idtyp 클레임 - 앱 전용 토큰에 대한 것이어야 "app" 합니다.
  • 클레임 없음 scp - 앱 전용 토큰에 범위 클레임이 없음
  • 테넌트 ID - 게시자 테넌트 ID와 일치해야 합니다.

샘플 앱 토큰 클레임:

{
  "aud": "api://localdevinstance/aaaabbbb-0000-cccc-1111-dddd2222eeee/Fabric.WorkloadSample/123",
  "iss": "https://sts.windows.net/12345678-77f3-4fcc-bdaa-487b920cb7ee/",
  "iat": 1700047232,
  "nbf": 1700047232,
  "exp": 1700133932,
  "appid": "11112222-bbbb-3333-cccc-4444dddd5555",
  "appidacr": "2",
  "idtyp": "app",
  "oid": "aaaaaaaa-0000-1111-2222-bbbbbbbbbbbb",
  "tid": "bbbbcccc-1111-dddd-2222-eeee3333ffff",
  "ver": "1.0"
}

주체 토큰 유효성 검사

사용자가 subjectToken 위임한 토큰입니다. 유효성 검사:

  • 토큰 서명 - Azure AD 서명 키에 대해 확인
  • 토큰 수명 - 확인 nbfexp 클레임
  • 대상 그룹 - 워크로드의 앱 등록과 일치해야 합니다.
  • Issuer - 올바른 테넌트가 있는 Azure AD에서 온 것이어야 합니다.
  • scp 클레임 - 범위를 포함해야 "FabricWorkloadControl" 합니다.
  • 클레임 없음 idtyp - 위임된 토큰에 이 클레임이 없습니다.
  • appid 일치해야 합니다 . appToken과 동일해야 합니다.

샘플 subjectToken 클레임:

{
  "aud": "api://localdevinstance/aaaabbbb-0000-cccc-1111-dddd2222eeee/Fabric.WorkloadSample/123",
  "iss": "https://sts.windows.net/12345678-77f3-4fcc-bdaa-487b920cb7ee/",
  "iat": 1700050446,
  "nbf": 1700050446,
  "exp": 1700054558,
  "appid": "11112222-bbbb-3333-cccc-4444dddd5555",
  "scp": "FabricWorkloadControl",
  "name": "john doe",
  "oid": "bbbbbbbb-1111-2222-3333-cccccccccccc",
  "upn": "user1@contoso.com",
  "tid": "bbbbcccc-1111-dddd-2222-eeee3333ffff",
  "ver": "1.0"
}

JavaScript 토큰 유효성 검사 예제

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

/**
 * Validate Azure AD token
 * @param {string} token - JWT token to validate
 * @param {object} options - Validation options
 * @returns {Promise<object>} Validated token claims
 */
async function validateAadToken(token, options = {}) {
  const { isAppOnly = false, tenantId = null, audience = null } = options;

  // Decode token to get header and tenant
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded) {
    throw new Error('Invalid token format');
  }

  const { header, payload } = decoded;
  const tokenTenantId = payload.tid;

  // Get signing key from Azure AD
  const client = jwksClient({
    jwksUri: `https://login.microsoftonline.com/${tokenTenantId}/discovery/v2.0/keys`,
    cache: true,
    cacheMaxAge: 86400000 // 24 hours
  });

  const signingKey = await new Promise((resolve, reject) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) reject(err);
      else resolve(key.getPublicKey());
    });
  });

  // Verify token signature and claims
  const verifyOptions = {
    algorithms: ['RS256'],
    audience: audience,
    clockTolerance: 60 // 1 minute clock skew
  };

  const verified = jwt.verify(token, signingKey, verifyOptions);

  // Validate app-only vs delegated token
  if (isAppOnly) {
    if (verified.idtyp !== 'app') {
      throw new Error('Expected app-only token');
    }
    if (verified.scp) {
      throw new Error('App-only token should not have scp claim');
    }
  } else {
    if (verified.idtyp) {
      throw new Error('Expected delegated token');
    }
    if (!verified.scp || !verified.scp.includes('FabricWorkloadControl')) {
      throw new Error('Missing required FabricWorkloadControl scope');
    }
  }

  // Validate tenant if required
  if (tenantId && verified.tid !== tenantId) {
    throw new Error(`Token tenant mismatch: expected ${tenantId}, got ${verified.tid}`);
  }

  return verified;
}

인증 미들웨어

패브릭에서 들어오는 요청을 인증하는 미들웨어를 구현합니다.

전체 인증 흐름

/**
 * Authentication middleware that validates control plane calls from Fabric
 * Handles both user-delegated (with subjectToken) and app-only (without subjectToken) scenarios
 * @param {object} req - Express request object
 * @param {object} res - Express response object
 * @param {object} options - Authentication options
 * @param {boolean} options.requireSubjectToken - Whether subject token is required (default: false)
 * @returns {Promise<boolean>} True if authenticated successfully
 */
async function authenticateControlPlaneCall(req, res, options = {}) {
  const { requireSubjectToken = false } = options;
  try {
    // Extract and parse authorization header
    const authHeader = req.headers['authorization'];
    if (!authHeader) {
      return res.status(401).json({ error: 'Missing Authorization header' });
    }

    const tokens = parseSubjectAndAppToken(authHeader);
    if (!tokens || !tokens.appToken) {
      return res.status(401).json({ error: 'Invalid Authorization header format' });
    }

    // Get tenant ID from header
    const tenantId = req.headers['ms-client-tenant-id'];
    if (!tenantId) {
      return res.status(400).json({ error: 'Missing ms-client-tenant-id header' });
    }

    // Get configuration
    const publisherTenantId = process.env.TENANT_ID;
    const audience = process.env.BACKEND_AUDIENCE;
    const fabricBackendAppId = '00000009-0000-0000-c000-000000000000';

    // Validate app token (from Fabric)
    const appTokenClaims = await validateAadToken(tokens.appToken, {
      isAppOnly: true,
      audience: audience
    });

    // Verify app token is from Fabric
    const appTokenAppId = appTokenClaims.appid || appTokenClaims.azp;
    if (appTokenAppId !== fabricBackendAppId) {
      return res.status(401).json({ error: 'App token not from Fabric' });
    }

    // Verify app token is in publisher's tenant
    if (publisherTenantId && appTokenClaims.tid !== publisherTenantId) {
      return res.status(401).json({ error: 'App token tenant mismatch' });
    }

    // Validate subject token if present
    let subjectTokenClaims = null;
    if (tokens.subjectToken) {
      subjectTokenClaims = await validateAadToken(tokens.subjectToken, {
        isAppOnly: false,
        audience: audience,
        tenantId: tenantId
      });

      // Verify subject token has same appid as app token
      const subjectAppId = subjectTokenClaims.appid || subjectTokenClaims.azp;
      if (subjectAppId !== appTokenAppId) {
        return res.status(401).json({ error: 'Token appid mismatch' });
      }
    } else if (requireSubjectToken) {
      // If subject token is required but missing, reject the request
      return res.status(401).json({ 
        error: 'Subject token required for this operation' 
      });
    }

    // Store authentication context in request
    req.authContext = {
      subjectToken: tokens.subjectToken,
      appToken: tokens.appToken,
      tenantId: tenantId,
      hasSubjectContext: !!tokens.subjectToken,
      appTokenClaims: appTokenClaims,
      subjectTokenClaims: subjectTokenClaims,
      userId: subjectTokenClaims?.oid || subjectTokenClaims?.sub,
      userName: subjectTokenClaims?.name || subjectTokenClaims?.upn
    };

    return true; // Authentication successful

  } catch (error) {
    console.error('Authentication failed:', error.message);
    res.status(401).json({ error: 'Authentication failed' });
    return false;
  }
}

미들웨어 사용

app.post('/api/jobs/execute', async (req, res) => {
  // Authenticate the request (subjectToken is optional)
  const authenticated = await authenticateControlPlaneCall(req, res);
  if (!authenticated) {
    return; // Response already sent by middleware
  }

  // Access authentication context
  const { hasSubjectContext, userId, userName, tenantId, subjectToken } = req.authContext;
  
  if (hasSubjectContext) {
    console.log(`Executing job for user: ${userName} (${userId})`);
  } else {
    console.log('Executing job in app-only context (no user)');
  }
  
  // Execute job logic...
});

// Example: Require user context for specific operations
app.post('/api/lifecycle/create', async (req, res) => {
  // For create operations, we might require user context
  const authenticated = await authenticateControlPlaneCall(req, res, {
    requireSubjectToken: true
  });
  if (!authenticated) {
    return; // Response already sent by middleware
  }

  // User context is guaranteed to be present here
  const { userId, userName } = req.authContext;
  console.log(`Creating item for user: ${userName}`);
  
  // Handle creation...
});

// Example: Allow app-only context for delete operations
app.post('/api/lifecycle/delete', async (req, res) => {
  // Delete operations might not have user context
  const authenticated = await authenticateControlPlaneCall(req, res, {
    requireSubjectToken: false // Default, but shown for clarity
  });
  if (!authenticated) {
    return;
  }

  const { hasSubjectContext, userName } = req.authContext;
  if (hasSubjectContext) {
    console.log(`Deleting item for user: ${userName}`);
  } else {
    console.log('Deleting item (system operation)');
  }
  
  // Handle deletion...
});

토큰 교환 - OBO(on-Behalf-Of) 흐름

패브릭 API를 호출하거나 원격 엔드포인트에서 OneLake에 액세스하려면 OAuth 2.0 On-Behalf-Of 흐름을 사용하여 사용자의 subjectToken을 리소스별 토큰으로 교환해야 합니다.

토큰 거래소 서비스

const https = require('https');
const { URL } = require('url');

const AAD_LOGIN_URL = 'https://login.microsoftonline.com';
const ONELAKE_SCOPE = 'https://storage.azure.com/.default';
const FABRIC_SCOPE = 'https://analysis.windows.net/powerbi/api/.default';

/**
 * Get token for any scope using OBO flow
 * @param {string} userToken - User's access token (subjectToken)
 * @param {string} tenantId - User's tenant ID
 * @param {string} scope - Target resource scope
 * @returns {Promise<string>} Access token for the requested scope
 * @throws {Error} Throws consent-related errors (AADSTS65001, AADSTS65005) that should be propagated to UX
 */
async function getTokenForScope(userToken, tenantId, scope) {
  const clientId = process.env.BACKEND_APPID;
  const clientSecret = process.env.BACKEND_CLIENT_SECRET;

  if (!clientId || !clientSecret) {
    throw new Error('BACKEND_APPID and BACKEND_CLIENT_SECRET required');
  }

  const tokenEndpoint = `${AAD_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`;
  const requestBody = new URLSearchParams({
    grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    client_id: clientId,
    client_secret: clientSecret,
    assertion: userToken,
    scope: scope,
    requested_token_use: 'on_behalf_of'
  }).toString();

  const response = await makeTokenRequest(tokenEndpoint, requestBody);

  if (!response.access_token) {
    throw new Error('Token exchange failed: missing access_token');
  }

  return response.access_token;
}

/**
 * Get OneLake access token
 * @param {string} userToken - User's access token from authContext
 * @param {string} tenantId - User's tenant ID
 * @returns {Promise<string>} OneLake access token
 */
async function getOneLakeToken(userToken, tenantId) {
  return getTokenForScope(userToken, tenantId, ONELAKE_SCOPE);
}

/**
 * Get Fabric OBO token for calling Fabric APIs
 * @param {string} userToken - User's access token
 * @param {string} tenantId - User's tenant ID
 * @returns {Promise<string>} Fabric OBO token
 */
async function getFabricOboToken(userToken, tenantId) {
  return getTokenForScope(userToken, tenantId, FABRIC_SCOPE);
}

/**
 * Make HTTPS request to token endpoint
 */
function makeTokenRequest(url, body) {
  return new Promise((resolve, reject) => {
    const parsedUrl = new URL(url);
    const options = {
      hostname: parsedUrl.hostname,
      port: 443,
      path: parsedUrl.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(body)
      }
    };

    const req = https.request(options, (res) => {
      let responseBody = '';
      res.on('data', (chunk) => { responseBody += chunk; });
      res.on('end', () => {
        const parsed = JSON.parse(responseBody);
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(parsed);
        } else {
          const error = parsed.error_description || parsed.error || `HTTP ${res.statusCode}`;
          reject(new Error(error));
        }
      });
    });

    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

토큰 교환 사용

// In your job or lifecycle notification handler
async function handleJobExecution(req, res) {
  const authenticated = await authenticateControlPlaneCall(req, res);
  if (!authenticated) return;

  const { hasSubjectContext, subjectToken, tenantId } = req.authContext;

  try {
    // Only exchange token if user context is available
    if (hasSubjectContext) {
      // Get OneLake token to access item data on behalf of the user
      const oneLakeToken = await getOneLakeToken(subjectToken, tenantId);
      
      // Use OneLake token to read/write data
      const data = await readFromOneLake(oneLakeToken, workspaceId, itemId);
      
      // Process data...
    } else {
      // Handle app-only scenario
      // You may need to use different authentication or skip user-specific operations
      console.log('Processing job without user context');
      // Use workspace or item-level access instead
    }
    
    res.status(200).json({ status: 'completed' });
  } catch (error) {
    console.error('Job execution failed:', error);
    res.status(500).json({ error: 'Job execution failed' });
  }
}

Fabric API 호출

패브릭 워크로드 제어 API를 호출하려면 OBO 및 S2S 토큰을 모두 사용하여 SubjectAndAppToken을 생성해야 합니다.

S2S(서비스 대 서비스) 토큰

OBO 토큰 외에도 appToken 부분에 대한 앱 전용 S2S 토큰이 필요합니다.

/**
 * Get S2S (Service-to-Service) token using client credentials flow
 * @param {string} tenantId - Publisher's tenant ID
 * @param {string} scope - Target resource scope
 * @returns {Promise<string>} S2S access token
 */
async function getS2STokenForScope(tenantId, scope) {
  const clientId = process.env.BACKEND_APPID;
  const clientSecret = process.env.BACKEND_CLIENT_SECRET;

  if (!clientId || !clientSecret) {
    throw new Error('BACKEND_APPID and BACKEND_CLIENT_SECRET required');
  }

  const tokenEndpoint = `${AAD_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`;
  const requestBody = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
    scope: scope
  }).toString();

  const response = await makeTokenRequest(tokenEndpoint, requestBody);

  if (!response.access_token) {
    throw new Error('S2S token acquisition failed');
  }

  return response.access_token;
}

/**
 * Get Fabric S2S token
 * @param {string} publisherTenantId - Publisher's tenant ID
 * @returns {Promise<string>} Fabric S2S access token
 */
async function getFabricS2SToken(publisherTenantId) {
  return getS2STokenForScope(publisherTenantId, FABRIC_SCOPE);
}

패브릭 API에 대한 복합 토큰 빌드

/**
 * Build composite token for Fabric API calls
 * @param {object} authContext - Authentication context
 * @returns {Promise<string>} SubjectAndAppToken formatted header value
 */
async function buildCompositeToken(authContext) {
  const { subjectToken, tenantId } = authContext;
  const publisherTenantId = process.env.TENANT_ID;

  if (!subjectToken) {
    throw new Error('Subject token is required');
  }

  // Exchange user's subject token for Fabric OBO token
  const fabricOboToken = await getFabricOboToken(subjectToken, tenantId);

  // Acquire S2S token using publisher tenant
  const fabricS2SToken = await getFabricS2SToken(publisherTenantId);

  // Combine into SubjectAndAppToken format
  return `SubjectAndAppToken1.0 subjectToken="${fabricOboToken}", appToken="${fabricS2SToken}"`;
}

Fabric API 호출 예제

const axios = require('axios');

/**
 * Call Fabric workload control API
 */
async function callFabricApi(authContext, workspaceId, itemId) {
  // Build composite token
  const authHeader = await buildCompositeToken(authContext);

  // Call Fabric API
  const response = await axios.get(
    `https://api.fabric.microsoft.com/v1/workspaces/${workspaceId}/items/${itemId}`,
    {
      headers: {
        'Authorization': authHeader,
        'Content-Type': 'application/json'
      }
    }
  );

  return response.data;
}

전체 예제: 인증을 사용한 작업 실행

다음은 인증을 사용하는 작업 실행 엔드포인트의 전체 예제입니다.

const express = require('express');
const app = express();

app.post('/api/jobs/:jobType/instances/:instanceId', async (req, res) => {
  // Authenticate the request from Fabric
  const authenticated = await authenticateControlPlaneCall(req, res);
  if (!authenticated) {
    return; // Response already sent
  }

  const { jobType, instanceId } = req.params;
  const { workspaceId, itemId } = req.body;
  const { subjectToken, tenantId, userId } = req.authContext;

  console.log(`Starting job ${jobType} for user ${userId}`);

  try {
    // Get OneLake token to access item data
    const oneLakeToken = await getOneLakeToken(subjectToken, tenantId);

    // Read data from OneLake
    const itemData = await readItemData(oneLakeToken, workspaceId, itemId);

    // Process the job
    const result = await processJob(jobType, itemData);

    // Write results back to OneLake
    await writeResults(oneLakeToken, workspaceId, itemId, result);

    // Return success
    res.status(202).json({
      status: 'InProgress',
      instanceId: instanceId,
      message: 'Job started successfully'
    });

  } catch (error) {
    console.error(`Job ${instanceId} failed:`, error);
    res.status(500).json({
      status: 'Failed',
      instanceId: instanceId,
      error: error.message
    });
  }
});

장시간 실행 작업

작업과 같은 장기 실행 작업의 경우 토큰이 완료되기 전에 만료될 수 있습니다. 토큰 새로 고침 논리 구현:

// Store refresh tokens securely
const tokenCache = new Map();

/**
 * Get cached token or acquire new one
 */
async function getOrRefreshToken(userToken, tenantId, scope, cacheKey) {
  const cached = tokenCache.get(cacheKey);
  
  // Check if cached token is still valid (with 5 min buffer)
  if (cached && cached.expiresAt > Date.now() + 300000) {
    return cached.token;
  }

  // Acquire new token
  const response = await getTokenForScopeWithRefresh(userToken, tenantId, scope);
  
  // Cache the token
  tokenCache.set(cacheKey, {
    token: response.access_token,
    expiresAt: Date.now() + (response.expires_in * 1000)
  });

  return response.access_token;
}

장기 실행 OBO 프로세스 처리에 대한 자세한 내용은 장기 실행 OBO 프로세스를 참조하세요.

보안 모범 사례

토큰 스토리지

  • 전체 토큰을 기록하지 마세요. 디버깅을 위해 마지막 4자만 기록합니다.
  • 메모리에만 안전하게 토큰 저장
  • 사용 후 메모리에서 토큰 지우기
  • 디스크 또는 데이터베이스에 토큰 유지 안 함

토큰 유효성 검사

  • 항상 SubjectAndAppToken 형식으로 appToken의 유효성을 검사합니다.
  • subjectToken이 있는 경우에만 유효성 검사
  • Azure AD 키에 대한 토큰 서명 확인
  • 토큰 만료 및 이전 시간 확인
  • 발급자, 대상 그룹 및 테넌트 클레임의 유효성 검사
  • 필요한 클레임 확인(scp, idtyp 등)

누락된 사용자 컨텍스트 처리

  • 사용자가 위임한 시나리오와 앱 전용 시나리오를 모두 처리하도록 원격 엔드포인트 디자인
  • 사용자별 속성에 액세스하기 전에 authContexthasSubjectContext를 확인하세요.
  • 사용자 컨텍스트가 필요한 작업의 경우 미들웨어 옵션에서 설정합니다 requireSubjectToken: true .
  • 삭제 작업 또는 시스템 작업의 경우 누락된 subjectToken을 허용합니다.
  • 앱 전용 컨텍스트를 지원하는 작업 및 사용자 컨텍스트가 필요한 작업 문서화

환경 구성

// Required environment variables
const requiredEnvVars = [
  'BACKEND_APPID',           // Your workload's app registration ID
  'BACKEND_CLIENT_SECRET',   // Your app's client secret
  'TENANT_ID',               // Your publisher tenant ID
  'BACKEND_AUDIENCE'         // Expected token audience
];

// Validate on startup
requiredEnvVars.forEach(varName => {
  if (!process.env[varName]) {
    throw new Error(`Missing required environment variable: ${varName}`);
  }
});

동의 또는 범위 누락으로 인해 토큰 교환이 실패하면 백 엔드에서 이러한 오류를 프런트 엔드 UX로 전파하여 사용자에게 동의를 요청해야 합니다. 일반적인 동의 관련 오류는 다음과 같습니다.

  • AADSTS65001 - 사용자 또는 관리자가 애플리케이션 사용에 동의하지 않았습니다.
  • AADSTS65005 - 애플리케이션에 대해 아직 동의되지 않은 특정 범위가 필요합니다.
/**
 * Handle token exchange with consent error propagation
 * Consent errors should be returned to the frontend to trigger consent flow
 */
async function handleTokenExchangeWithConsent(req, res) {
  const { subjectToken, tenantId } = req.authContext;
  const clientId = process.env.BACKEND_APPID;
  
  try {
    const token = await getTokenForScope(subjectToken, tenantId, ONELAKE_SCOPE);
    // Use token for OneLake operations...
    
  } catch (error) {
    // Check for consent-related errors
    if (error.message.includes('AADSTS65001') || error.message.includes('AADSTS65005')) {
      // Extract scope from error if available
      const requiredScope = extractScopeFromError(error.message) || 'https://storage.azure.com/.default';
      
      // Build consent URL for the frontend to redirect the user
      const consentUrl = buildConsentUrl({
        clientId: clientId,
        tenantId: tenantId,
        scope: requiredScope,
        redirectUri: process.env.FRONTEND_URL || 'https://your-frontend.azurewebsites.net'
      });
      
      // Return consent required response
      return res.status(403).json({
        error: 'ConsentRequired',
        errorCode: error.message.includes('AADSTS65001') ? 'AADSTS65001' : 'AADSTS65005',
        message: 'User consent is required to access this resource',
        consentUrl: consentUrl,
        requiredScope: requiredScope
      });
    }
    
    // Other errors should be handled normally
    throw error;
  }
}

/**
 * Build consent URL for user authorization
 * @param {object} options - Consent URL options
 * @returns {string} Formatted consent URL
 */
function buildConsentUrl(options) {
  const { clientId, tenantId, scope, redirectUri } = options;
  
  const params = new URLSearchParams({
    client_id: clientId,
    response_type: 'code',
    redirect_uri: redirectUri,
    response_mode: 'query',
    scope: scope,
    state: 'consent_required' // Your frontend can use this to handle the redirect
  });
  
  return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
}

/**
 * Extract scope from error message
 */
function extractScopeFromError(errorMessage) {
  // Azure AD error messages often include the missing scope
  const scopeMatch = errorMessage.match(/scope[s]?[:\s]+([^\s,]+)/);
  return scopeMatch ? scopeMatch[1] : null;
}

프런트 엔드에서 ConsentRequired 오류가 발생하면 사용자를 동의 URL로 리디렉션해야 합니다.

// Frontend code to handle consent errors
async function callBackendApi(endpoint, data) {
  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    
    if (response.status === 403) {
      const error = await response.json();
      
      if (error.error === 'ConsentRequired' && error.consentUrl) {
        // Redirect user to consent page
        window.location.href = error.consentUrl;
        return;
      }
    }
    
    return await response.json();
    
  } catch (error) {
    console.error('API call failed:', error);
    throw error;
  }
}

메모

패브릭 워크로드 개발 샘플에는 프런트 엔드 UX에 대한 동의 오류 처리 및 전파의 전체 예제가 포함되어 있습니다. 프로덕션 준비 구현에 대한 샘플의 인증 미들웨어 및 프런트 엔드 오류 처리를 참조하세요.

추가 오류 시나리오

// Handle various token exchange errors
try {
  const token = await getTokenForScope(subjectToken, tenantId, scope);
} catch (error) {
  const errorMessage = error.message;
  
  if (errorMessage.includes('AADSTS65001') || errorMessage.includes('AADSTS65005')) {
    // Consent required - propagate to frontend
    return propagateConsentError(res, error, tenantId);
    
  } else if (errorMessage.includes('AADSTS50013')) {
    // Invalid assertion - token may be expired
    return res.status(401).json({
      error: 'InvalidToken',
      message: 'The provided token is invalid or expired'
    });
    
  } else if (errorMessage.includes('AADSTS700016')) {
    // Application not found in tenant
    return res.status(400).json({
      error: 'ApplicationNotFound',
      message: 'Application is not configured in this tenant'
    });
  }
  
  // Unknown error
  throw error;
}

Troubleshooting

일반적인 인증 문제

오류: "잘못된 토큰 형식"

  • 권한 부여 헤더가 로 시작하는지 확인합니다. SubjectAndAppToken1.0
  • subjectToken과 appToken이 모두 있는지 확인합니다.
  • 헤더에서 토큰이 제대로 따옴표로 묶이도록 합니다.

오류: "토큰 유효성 검사 실패"

  • BACKEND_APPID 앱 등록과 일치하는지 확인
  • BACKEND_AUDIENCE 올바르게 구성되었는지 확인합니다.
  • 토큰이 만료되지 않았는지 확인
  • Azure AD 테넌트와 발급자가 일치하는지 확인하세요.

오류: "토큰 교환 실패: AADSTS65001"

  • 요청된 범위에 대한 사용자 동의가 필요합니다.
  • 앱 등록에 필요한 API 권한이 있는지 확인
  • 필요한 경우 관리자 동의가 부여되었는지 확인

오류: "패브릭에서 발급되지 않은 앱 토큰"

  • appToken의 appid 클레임이 패브릭의 앱 ID와 일치하는지 확인합니다.
  • 올바른 환경(개발/프로덕션)에서 테스트하고 있는지 확인합니다.