#!/usr/bin/env bash set -euo pipefail die() { echo "tapauth: $*" >&2; exit 1; } TAPAUTH_BASE="${TAPAUTH_BASE_URL:-https://tapauth.ai}" TAPAUTH_AGENT="${TAPAUTH_AGENT_NAME:-tapauth-skill}" TAPAUTH_POLL_TIMEOUT="${TAPAUTH_POLL_TIMEOUT_SECONDS:-600}" if [ -n "${TAPAUTH_HOME:-}" ]; then TAPAUTH_DIR="$TAPAUTH_HOME" elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then TAPAUTH_DIR="$CLAUDE_PLUGIN_DATA" else TAPAUTH_DIR="${HOME}/.tapauth" fi [ ! -L "$TAPAUTH_DIR" ] || die "cache directory must not be a symlink" mkdir -p "$TAPAUTH_DIR" if [ ! -d "$TAPAUTH_DIR" ] || [ ! -O "$TAPAUTH_DIR" ]; then die "cache directory must be owned by the current user" fi chmod 700 "$TAPAUTH_DIR" mode="url" fresh="false" key_id="" while [ $# -gt 0 ]; do case "$1" in --token) mode="token"; shift ;; --fresh|--no-cache) fresh="true"; shift ;; --key-id) [ $# -ge 2 ] || die "--key-id requires a value" key_id="$2"; shift 2 ;; --) shift; break ;; -*) die "unknown option: $1" ;; *) break ;; esac done [ "$fresh" = "false" ] || [ "$mode" = "url" ] || die "run --fresh without --token first, then run --token" emit_token() { [ -n "${TAPAUTH_TOKEN_B64:-}" ] || die "no token in response" printf '%s' "$TAPAUTH_TOKEN_B64" | base64 --decode 2>/dev/null || printf '%s' "$TAPAUTH_TOKEN_B64" | base64 -D printf '\n' exit 0 } parse_env_response() { while IFS= read -r line; do key="${line%%=*}" value="${line#*=}" value="${value%$'\r'}" case "$key" in TAPAUTH_GRANT_ID) TAPAUTH_GRANT_ID="$value" ;; TAPAUTH_GRANT_SECRET) TAPAUTH_GRANT_SECRET="$value" ;; TAPAUTH_APPROVE_URL) TAPAUTH_APPROVE_URL="$value" ;; TAPAUTH_STATUS) TAPAUTH_STATUS="$value" ;; TAPAUTH_EXPIRES) TAPAUTH_EXPIRES="$value" ;; TAPAUTH_TOKEN_B64) TAPAUTH_TOKEN_B64="$value" ;; esac done <<< "$1" } save_grant() { (umask 077; printf '%s\n' \ "TAPAUTH_GRANT_ID=${TAPAUTH_GRANT_ID}" \ "TAPAUTH_GRANT_SECRET=${TAPAUTH_GRANT_SECRET}" \ "TAPAUTH_EXPIRES=${TAPAUTH_EXPIRES:-}" > "$env_file") chmod 600 "$env_file" } fetch_grant() { TAPAUTH_TOKEN_B64="" TAPAUTH_STATUS="" TAPAUTH_EXPIRES="" TAPAUTH_APPROVE_URL="" local resp resp="$(printf 'header = "Authorization: Bearer %s"\n' "$TAPAUTH_GRANT_SECRET" | \ curl --config - --silent --show-error --write-out "\n%{http_code}" \ -H 'Accept: text/plain' "${TAPAUTH_BASE}/api/v1/grants/${TAPAUTH_GRANT_ID}" || true)" TAPAUTH_HTTP="${resp##*$'\n'}" parse_env_response "${resp%$'\n'*}" [ "$TAPAUTH_HTTP" != "000" ] || die "failed to contact TapAuth" } emit_url() { if [ "$provider" = "secret" ]; then echo "Approve secret request: ${TAPAUTH_APPROVE_URL:-${TAPAUTH_BASE}/approve/${TAPAUTH_GRANT_ID}}" else echo "Approve access: ${TAPAUTH_APPROVE_URL:-${TAPAUTH_BASE}/approve/${TAPAUTH_GRANT_ID}}" fi echo "" if [ "${TAPAUTH_STATUS:-}" = "expired" ]; then echo "Show this URL to the user, then start --token immediately; it waits until re-authorization completes." else echo "Show this URL to the user, then start --token immediately; it waits until approval completes." fi exit 0 } create_grant() { if [ "$provider" = "secret" ]; then echo "Creating secret request..." >&2 else echo "Creating grant for ${provider}${sorted_scopes:+ (${sorted_scopes})}..." >&2 fi TAPAUTH_GRANT_ID="" TAPAUTH_GRANT_SECRET="" TAPAUTH_APPROVE_URL="" TAPAUTH_EXPIRES="" TAPAUTH_STATUS="" create_args=(curl --silent --show-error --write-out "\n%{http_code}" -X POST -H 'Accept: text/plain' --data-urlencode "provider=${provider}" --data-urlencode "agent_name=${TAPAUTH_AGENT}") if [ "$provider" = "secret" ]; then create_args+=(--data-urlencode "secret_key_id=${key_id}") create_args+=(--data-urlencode "secret_description=${secret_description}") [ -n "$validation_regex" ] && create_args+=(--data-urlencode "validation_regex=${validation_regex}") [ -n "$validation_hint" ] && create_args+=(--data-urlencode "validation_hint=${validation_hint}") else [ -n "$sorted_scopes" ] && create_args+=(--data-urlencode "scopes=${sorted_scopes}") fi create_args+=("${TAPAUTH_BASE}/api/v1/grants") local resp resp="$("${create_args[@]}" || true)" TAPAUTH_HTTP="${resp##*$'\n'}" parse_env_response "${resp%$'\n'*}" case "$TAPAUTH_HTTP" in 200|201) ;; 000) die "failed to contact TapAuth" ;; *) die "failed to create grant (${TAPAUTH_HTTP})" ;; esac if [ -z "${TAPAUTH_GRANT_ID:-}" ] || [ -z "${TAPAUTH_GRANT_SECRET:-}" ]; then die "failed to create grant" fi save_grant } provider="${1:-}" raw_scopes="${2:-}" validation_regex="${3:-}" validation_hint="${4:-}" if [ -z "${provider:-}" ]; then echo "usage: tapauth [--token] [--fresh] [scopes]" >&2 echo " tapauth [--fresh] --key-id secret [validation_regex] [validation_hint]" >&2 echo " tapauth --token --key-id secret" >&2 echo " providers: google, github, linear, vercel, slack, notion, asana, sentry, discord, apify, atlassian, secret" >&2 exit 1 fi case "$provider" in google|github|linear|vercel|slack|notion|asana|sentry|discord|apify|atlassian|secret) ;; *) die "unsupported provider: $provider" ;; esac if [ "$provider" = "secret" ]; then case "$key_id" in ""|*[!A-Za-z0-9._-]*|[-._]*) die "secret requires --key-id with letters, numbers, dot, underscore, or hyphen" ;; esac [ "${#key_id}" -le 128 ] || die "--key-id must be 128 characters or fewer" if [ -z "${raw_scopes:-}" ] && [ "$mode" != "token" ]; then echo "tapauth: description is required for secret" >&2 echo "usage: tapauth [--fresh] --key-id secret [validation_regex] [validation_hint]" >&2 exit 1 fi secret_description="$raw_scopes" sorted_scopes="" safe_scopes="$key_id" else [ -z "$key_id" ] || die "--key-id is only valid with the secret provider" scopes="$raw_scopes" [ -n "$scopes" ] || [ "$provider" != "vercel" ] || scopes="project" [ -n "$scopes" ] || [ "$provider" != "notion" ] || scopes="read_content" sorted_scopes=$(printf '%s' "$scopes" | tr "," "\n" | sort | tr "\n" ",") sorted_scopes="${sorted_scopes%,}" safe_scopes="${sorted_scopes//\//_}" safe_scopes="${safe_scopes//:/_}" fi env_file="${TAPAUTH_DIR}/${provider}-${safe_scopes}.env" if [ -e "$env_file" ] || [ -L "$env_file" ]; then if [ ! -f "$env_file" ] || [ -L "$env_file" ] || [ ! -O "$env_file" ]; then die "grant cache must be a user-owned regular file" fi chmod 600 "$env_file" fi TAPAUTH_GRANT_ID="" TAPAUTH_GRANT_SECRET="" TAPAUTH_EXPIRES="" TAPAUTH_APPROVE_URL="" TAPAUTH_STATUS="" TAPAUTH_TOKEN_B64="" [ "$fresh" = "true" ] || [ ! -f "$env_file" ] || parse_env_response "$(< "$env_file")" if [ -z "${TAPAUTH_GRANT_ID:-}" ] || [ -z "${TAPAUTH_GRANT_SECRET:-}" ]; then [ "$mode" = "token" ] && die "run without --token first to get an approval URL" create_grant emit_url fi fetch_grant case "$TAPAUTH_HTTP:${TAPAUTH_STATUS:-}" in 200:*) if [ "$mode" = "url" ]; then echo "Already authorized for ${provider}${sorted_scopes:+ (${sorted_scopes})}. Use --token to retrieve it." exit 0 fi save_grant emit_token ;; 202:*) ;; 410:expired) [ "$mode" = "token" ] && die "cached grant expired; run without --token first to re-authorize it" emit_url ;; 401:*|404:*|410:revoked|410:denied|410:link_expired|410:*) [ "$mode" = "token" ] && 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 (${TAPAUTH_HTTP})" ;; esac [ "$mode" = "url" ] && emit_url poll_start=$SECONDS while true; do sleep 2 elapsed=$((SECONDS - poll_start)) [ "$elapsed" -ge "$TAPAUTH_POLL_TIMEOUT" ] && { echo "tapauth: timed out" >&2; exit 1; } echo "Waiting for approval... (${elapsed}s)" >&2 fetch_grant case "$TAPAUTH_HTTP:${TAPAUTH_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 ${TAPAUTH_STATUS}" ;; 401:*|404:*|410:*) die "grant is no longer usable; run without --token first to get a new approval URL" ;; *) die "grant fetch failed (${TAPAUTH_HTTP})" ;; esac done