#!/usr/bin/env bash set -euo pipefail die() { echo "tapauth: $*" >&2; exit 1; } base="${TAPAUTH_BASE_URL:-https://tapauth.ai}" agent="${TAPAUTH_AGENT_NAME:-tapauth-skill}" poll_timeout="${TAPAUTH_POLL_TIMEOUT_SECONDS:-600}" if [ -n "${TAPAUTH_HOME:-}" ]; then cache_dir="$TAPAUTH_HOME" elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then cache_dir="$CLAUDE_PLUGIN_DATA" else cache_dir="$HOME/.tapauth" fi [ ! -L "$cache_dir" ] || die "cache directory must not be a symlink" mkdir -p "$cache_dir" if [ ! -d "$cache_dir" ] || [ ! -O "$cache_dir" ]; then die "cache directory must be owned by the current user" fi chmod 700 "$cache_dir" mode=url fresh=false while [ $# -gt 0 ]; do case "$1" in --token) mode=token; shift ;; --fresh|--no-cache) fresh=true; shift ;; --) shift; break ;; -*) die "unknown option: $1" ;; *) break ;; esac done [ "$fresh" = false ] || [ "$mode" = url ] || die "run --fresh without --token first, then run --token" provider="${1:-}" scopes="${2:-}" [ -n "$provider" ] || { echo "usage: tapauth [--token] [--fresh] [scopes]" >&2 echo " providers: google, github, linear, vercel, slack, notion, asana, sentry, discord, apify, atlassian" >&2 exit 1 } case "$provider" in google|github|linear|vercel|slack|notion|asana|sentry|discord|apify|atlassian) ;; *) die "unsupported provider: $provider" ;; esac [ -n "$scopes" ] || [ "$provider" != vercel ] || scopes=project [ -n "$scopes" ] || [ "$provider" != notion ] || scopes=read_content sorted_scopes=$(printf '%s' "$scopes" | tr ',' '\n' | LC_ALL=C sort -u | tr '\n' ',') sorted_scopes="${sorted_scopes%,}" safe_scopes="${sorted_scopes//\//_}" safe_scopes="${safe_scopes//:/_}" cache_file="$cache_dir/$provider-$safe_scopes.env" if [ -e "$cache_file" ] || [ -L "$cache_file" ]; then if [ ! -f "$cache_file" ] || [ -L "$cache_file" ] || [ ! -O "$cache_file" ]; then die "grant cache must be a user-owned regular file" fi chmod 600 "$cache_file" fi parse_response() { while IFS= read -r line; do key="${line%%=*}" value="${line#*=}" value="${value%$'\r'}" case "$key" in TAPAUTH_GRANT_ID) grant_id="$value" ;; TAPAUTH_GRANT_SECRET) grant_secret="$value" ;; TAPAUTH_APPROVE_URL) approval_url="$value" ;; TAPAUTH_STATUS) status="$value" ;; TAPAUTH_EXPIRES) expires="$value" ;; TAPAUTH_TOKEN_B64) token_b64="$value" ;; esac done <<< "$1" } validate_grant() { case "$grant_id" in ''|*[!A-Za-z0-9-]*) die "invalid grant id" ;; esac case "$grant_secret" in ''|*[!A-Za-z0-9._-]*) die "invalid grant secret" ;; esac } save_grant() { (umask 077 printf '%s\n' "TAPAUTH_GRANT_ID=$grant_id" > "$cache_file" printf '%s\n' "TAPAUTH_GRANT_SECRET=$grant_secret" >> "$cache_file" printf '%s\n' "TAPAUTH_EXPIRES=${expires:-}" >> "$cache_file" ) chmod 600 "$cache_file" } emit_token() { [ -n "${token_b64:-}" ] || die "no token in response" local token token="$(printf '%s' "$token_b64" | base64 --decode 2>/dev/null)" || token="$(printf '%s' "$token_b64" | base64 -D 2>/dev/null)" || die "invalid token response" printf '%s\n' "$token" exit 0 } emit_url() { echo "Approve access: ${approval_url:-$base/approve/$grant_id}" echo echo "Show this URL to the user, then start --token immediately; it waits until approval completes." exit 0 } fetch_grant() { token_b64='' status='' expires='' approval_url='' local response local args=(--config - --silent --show-error --write-out "\n%{http_code}" -H 'Accept: text/plain' "$base/api/v1/grants/$grant_id") response="$(printf 'header = "Authorization: Bearer %s"\n' "$grant_secret" | curl "${args[@]}" || true)" http_status="${response##*$'\n'}" parse_response "${response%$'\n'*}" [ "$http_status" != 000 ] || die "failed to contact TapAuth" } create_grant() { echo "Creating grant for $provider${sorted_scopes:+ ($sorted_scopes)}..." >&2 grant_id='' grant_secret='' approval_url='' expires='' status='' local response local args=(--silent --show-error --write-out "\n%{http_code}" -X POST -H 'Accept: text/plain' --data-urlencode "provider=$provider" --data-urlencode "agent_name=$agent") [ -z "$sorted_scopes" ] || args+=(--data-urlencode "scopes=$sorted_scopes") response="$(curl "${args[@]}" "$base/api/v1/grants" || true)" http_status="${response##*$'\n'}" parse_response "${response%$'\n'*}" case "$http_status" in 200|201) if [ -z "$grant_id" ] || [ -z "$grant_secret" ]; then die "invalid response from TapAuth" fi validate_grant ;; 000) die "failed to contact TapAuth" ;; *) die "failed to create grant ($http_status)" ;; esac save_grant } grant_id='' grant_secret='' approval_url='' status='' expires='' token_b64='' [ "$fresh" = true ] || [ ! -f "$cache_file" ] || parse_response "$(< "$cache_file")" if [ -z "$grant_id" ] || [ -z "$grant_secret" ]; then [ "$mode" = url ] || die "run without --token first to get an approval URL" create_grant emit_url fi validate_grant fetch_grant case "$http_status:${status:-}" in 200:*) [ "$mode" = token ] || { echo "Already authorized for $provider${sorted_scopes:+ ($sorted_scopes)}. Use --token to retrieve it." exit 0 } save_grant emit_token ;; 202:*) ;; 410:expired) [ "$mode" = url ] || die "cached grant expired; run without --token first to re-authorize it" emit_url ;; 401:*|404:*|410:*) [ "$mode" = url ] || die "cached grant is no longer usable; run without --token first to get a new approval URL" create_grant emit_url ;; *) die "grant fetch failed ($http_status)" ;; esac [ "$mode" = token ] || emit_url poll_start=$SECONDS while true; do sleep 2 elapsed=$((SECONDS - poll_start)) [ "$elapsed" -lt "$poll_timeout" ] || die "timed out" echo "Waiting for approval... (${elapsed}s)" >&2 fetch_grant case "$http_status:${status:-}" in 200:*) save_grant; emit_token ;; 202:*) ;; 410:expired) die "grant expired; run without --token first to re-authorize it" ;; 410:revoked|410:denied|410:link_expired) die "grant $status" ;; 401:*|404:*|410:*) die "grant is no longer usable; run without --token first to get a new approval URL" ;; *) die "grant fetch failed ($http_status)" ;; esac done