Procore refresh_token failed: invalid_grant, message: The provided authorization grant is invalid, e
RCA: Procore refresh_token failed: invalid_grant
Error Log#
Procore refresh_token failed: invalid_grant, message: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. - error: 401 Unauthorized
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-04-20T03:57:07.402Z
- 최근 발생: 2026-04-20T03:57:07.402Z
- 영향 범위: calderstewart 팀(ID: 141)의 사용자 Tyler Bichan(ID: 5275)이 Procore integration(ID: 524)을 통해 access token을 요청할 때 400 에러가 반환됨. review key
2pujqr에서의 단일 요청 실패. 다른 팀(예: naylorlove)의 Procore 통합은 정상 동작 확인됨.
Root Cause Summary#
Procore integration(524)의 OAuth access token이 expired_at: 2026-02-17 20:58:20 UTC로, 요청 시점 기준 62일 이상 만료된 상태였다. 사용자가 POST /api/v1/reviews/2pujqr/integrations/procore/access_token을 호출하면서 IntegrationRepository#access_token이 만료 검사(expired_at < 10.minutes.since)를 통해 자동으로 refresh_token을 시도했으나, Procore OAuth 서버가 오래 만료된 refresh token에 대해 invalid_grant (401)를 반환했다. 핵심 원인은 Procore가 TOKEN_REFRESH_REQUIRED_PROVIDERS에 포함되지 않아 refresh_token_expired_at이 항상 nil로 설정되며, 이로 인해 cron job(Cupix::Cron::Integration.renew_before_expiration)의 refresh_token_due_to_expire 스코프에 걸리지 않아 사전 갱신이 이루어지지 않는 구조적 문제가 있다.
Technical Analysis#
Code Path#
- Entry point:
Api::V1::IntegrationsController#access_token—app/controllers/api/v1/integrations_controller.rb:35
# app/controllers/api/v1/integrations_controller.rb:35-38
def access_token
token = repository_instance.access_token
render_json 200, token
end
- 만료 검사 및 refresh 호출:
IntegrationRepository#access_token—app/repositories/integration_repository.rb:76-101
# app/repositories/integration_repository.rb:89-91
if @model.expired_at < 10.minutes.since
refresh_token # expired_at이 62일 전이므로 조건 충족 → refresh 시도
else
{ provider: @model.provider, access_token: @model.access_token, ... }
end
Integration 524의 expired_at이 2026-02-17 20:58:20 UTC이므로 10.minutes.since(현재 시각 + 10분)보다 과거 → refresh 호출.
- Refresh 실행:
IntegrationRepository#refresh_token—app/repositories/integration_repository.rb:103-170
# app/repositories/integration_repository.rb:106-128
Cupix::Logger.info("[Integration] refresh token for #{@model.provider} integration(#{@model.id}) - state: #{@model.state}, expired_at: #{@model.expired_at}, refresh_token_expired_at: #{@model.refresh_token_expired_at}")
# ... provider dispatch → ProcoreOperation
token = operation_class.refresh_token(@model.refresh_token, @model.region)
- Procore API 호출:
ProcoreOperation.refresh_token—app/operations/procore_operation.rb:42-73
# app/operations/procore_operation.rb:42-62
def self.refresh_token(refresh_token, region)
data = {
client_id: $OAUTH[:procore][:client_id],
client_secret: $OAUTH[:procore][:client_secret],
grant_type: 'refresh_token',
refresh_token: refresh_token,
redirect_uri: $OAUTH[:procore][:redirect_uri]
}
begin
url = "#{$OAUTH[:procore][:site]}#{$OAUTH[:procore][:refresh_url]}"
response = RestClient.post(url, data)
rescue RestClient::Exception => e
response = JSON.parse(e.response)
Cupix::Logger.error("Procore refresh_token failed: #{response['error']}, message: #{response['error_description']} - error: #{e.message}")
raise Cupix::Errors::Parameter.new(
code: 'ARG10000',
reason: "Procore Authentication failed: #{response['error']}",
message: response['error_description']
)
end
# ...
end
Failure point: app/operations/procore_operation.rb:53 — RestClient.post가 Procore OAuth 서버로부터 401 invalid_grant 응답을 받아 RestClient::Exception 발생.
- 실패 후 상태 전이:
app/repositories/integration_repository.rb:130-142
# app/repositories/integration_repository.rb:130-142
rescue StandardError => e
uncheck_refresh_request
raise e if e.code == 'ARG10060'
@model.refresh_token_failed_at = DateTime.now
@model.refresh_token_expired_at = nil
@model.refresh_token_response_body = token # 주의: token은 undefined (rescue 블록에서는 line 128이 실패하여 token 미할당)
@model.failed_state!
Cupix::Logger.error("[Integration] failed to refresh token for #{@model.provider} integration(#{@model.id}) - state: #{@model.state}, error_message: #{e.message}")
raise e
end
Integration이 failed 상태로 전이되고, 이후 동일 integration으로의 요청은 check_failed_state에서 차단됨.
- 사전 갱신 누락 원인:
TOKEN_REFRESH_REQUIRED_PROVIDERS에 Procore 미포함
# config/initializers/integration.rb:1
TOKEN_REFRESH_REQUIRED_PROVIDERS = %w[bim360].freeze
# app/repositories/integration_repository.rb:145-149
if TOKEN_REFRESH_REQUIRED_PROVIDERS.include?(@model.provider)
@model.refresh_token_expired_at = 14.days.since # bim360만 해당
else
@model.refresh_token_expired_at = nil # procore → 항상 nil
end
# app/models/concerns/statable/integration.rb:12
scope :refresh_token_due_to_expire, -> { not_failed.where.not(refresh_token_expired_at: nil).where('refresh_token_expired_at < ?', 1.days.since) }
refresh_token_expired_at이 nil이므로 cron job의 refresh_token_due_to_expire 스코프에서 제외됨 → 사전 갱신 불가.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-api status:error Procore
Time: 2026-04-20T02:57:00Z ~ 2026-04-20T04:30:00Z
service:cupixworks-api Procore
Time: 2026-04-20T03:50:00Z ~ 2026-04-20T04:10:00Z
실패 요청 타임라인 (Integration 524, request ID: 8ee61752-6d7a-4b23-b283-8fb7733c3eec):
- 03:57:07.402Z [INFO] — refresh 시도 전 상태 로그:
[Integration] refresh token for procore integration(524) - state: active, expired_at: 2026-02-17 20:58:20 UTC, refresh_token_expired_at:
expired_at이 2월 17일로 62일 만료 상태. refresh_token_expired_at은 비어있음(nil).
- 03:57:07.402Z [ERROR] — Procore OAuth 서버 응답 실패:
Procore refresh_token failed: invalid_grant, message: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. - error: 401 Unauthorized
- 03:57:07.402Z [ERROR] — integration 상태 failed로 전이:
[Integration] failed to refresh token for procore integration(524) - state: failed, error_message: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
- 03:57:07.645Z [INFO] — HTTP 응답 400 반환:
Controller: Api::V1::IntegrationsController#access_token
POST /api/v1/reviews/2pujqr/integrations/procore/access_token → 400
Duration: 385.81ms
User: tyler.bichan@calderstewart.co.nz (ID: 5275)
Team: calderstewart (ID: 141)
error.class: Cupix::Errors::Parameter, error.code: ARG10000
성공 사례 비교 (Integration 966, 10분 후):
[Integration] refresh token for procore integration(966) - state: active, expired_at: 2026-04-20 04:03:39 UTC, refresh_token_expired_at:
→ token이 4분 전 만료 → refresh 성공 → 200 응답. Procore OAuth 서버 자체에는 문제 없음 확인.
과거 이력 검색:
service:cupixworks-api status:error invalid_grant
Time: 2026-04-13T00:00:00Z ~ 2026-04-20T04:30:00Z
→ 지난 7일간 invalid_grant 에러는 이 1건뿐. 단발성 이벤트.
Fix Recommendation#
즉시 조치 (Critical)#
이 에러는 62일간 미사용된 Procore integration의 refresh token이 자연 만료되어 발생한 예상 가능한 운영 시나리오이다. 사용자가 Procore에서 재인증(re-authorize)하면 해결되므로, 즉시 코드 수정은 불필요하다.
로그 레벨 변경 고려: invalid_grant 에러는 사용자의 OAuth 토큰이 만료/취소된 경우 발생하는 것으로, 시스템 장애가 아닌 사용자 조치가 필요한 상황이다. error 레벨보다 warn 레벨이 적절할 수 있다.
app/operations/procore_operation.rb:57—Cupix::Logger.error→Cupix::Logger.warn검토app/repositories/integration_repository.rb:140—Cupix::Logger.error→Cupix::Logger.warn검토
단기 개선 (1주 이내)#
-
Procore를
TOKEN_REFRESH_REQUIRED_PROVIDERS에 추가 검토:config/initializers/integration.rb:1에서 Procore를 추가하면 성공적 refresh 후refresh_token_expired_at이 설정되어 cron job이 사전 갱신을 시도할 수 있다. 단, Procore의 refresh token 유효기간 정책을 확인한 후 적절한 기간(현재 bim360은 14일)을 설정해야 한다. -
token변수 undefined 버그 수정:app/repositories/integration_repository.rb:137에서@model.refresh_token_response_body = token은 rescue 블록 안에서 line 128의token할당이 실패한 경우 undefined이다. 에러 응답 본문이나 에러 메시지를 저장하도록 수정 필요.
장기 개선 (재발 방지)#
-
장기 미사용 integration에 대한 proactive 알림: 일정 기간(예: 30일) 이상 access token이 갱신되지 않은 integration에 대해 사용자에게 재인증을 안내하는 알림 메커니즘 검토.
-
통합 상태 대시보드: 팀별 integration 상태(active/failed/만료 임박)를 모니터링할 수 있는 admin 대시보드 검토.
Monitoring#
- 기존 에러 모니터링으로 충분.
invalid_grant에러가 특정 팀/integration에 반복 발생하는지 추적:
service:cupixworks-api "Procore refresh_token failed" @environment:production
- 로그 레벨을
warn으로 변경한 경우:
service:cupixworks-api status:warn "Procore refresh_token failed" @environment:production
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial
- 단발성 이벤트이며, 시스템 장애가 아닌 사용자의 OAuth 인증 만료로 인한 예상된 에러. 사용자 재인증으로 즉시 해결 가능. 코드 수정이 필요하다면 로그 레벨 변경(error → warn)이 적절하다.