Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 1b4a6cadf5 Add comprehensive logging to Apple callback handler
Log at every error point to debug where the callback is failing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:27:33 -08:00
adminandClaude Opus 4.5 ac852b7a8f Fix missing log import in apple_auth.go
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:20:45 -08:00
adminandClaude Opus 4.5 4c2e8d56d3 Add logging for Apple token exchange errors
Log the actual error from Apple and the parameters being used
to help debug token exchange failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:17:20 -08:00
adminandClaude Opus 4.5 61a742d142 Add Apple domain verification route to nginx
Apple Sign In requires domain verification. This adds routes to serve
the verification file from /.well-known/apple-developer-domain-association.txt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:51:17 -08:00
adminandClaude Opus 4.5 ae5a33863e Fetch GitHub email from /user/emails endpoint
GitHub only returns email in the /user endpoint if the user has made
their email public. For private emails, we need to call /user/emails.

This ensures we get the user's primary verified email even when they
have their email set to private on GitHub.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:18:49 -08:00
adminandClaude Opus 4.5 2129945604 Use exported env vars instead of .env file for deployment
The .env file approach was fragile for complex values like JSON
(JWT_PRIVATE_KEY) and base64 (APPLE_SIGNIN_PRIVATE_KEY).

Export environment variables directly in the deploy script so
docker compose reads them from the shell environment.

Also adds Apple Sign-In credentials to auth_build.yml workflow.

Note: Delete /opt/eagle0/.env on the server before deploying.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:49:16 -08:00
adminandClaude Opus 4.5 9136c27281 Use single quotes in .env to handle JSON and special chars
Double quotes don't work when values contain embedded quotes
(like JWT_PRIVATE_KEY JSON). Single quotes treat content literally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:49:16 -08:00
6 changed files with 144 additions and 86 deletions
+21 -25
View File
@@ -106,6 +106,10 @@ jobs:
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
@@ -114,16 +118,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -131,25 +125,27 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"GH_OAUTH_CLIENT_ID=${GH_OAUTH_CLIENT_ID}" \
"GH_OAUTH_CLIENT_SECRET=${GH_OAUTH_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
export AUTH_IMAGE="${AUTH_IMAGE}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
+28 -57
View File
@@ -215,69 +215,40 @@ jobs:
set -ex
cd /opt/eagle0
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
APPLE_TEAM_ID="${APPLE_TEAM_ID}"
APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Export environment variables for docker compose
# These are passed via heredoc and exported so child processes (docker compose) can access them
export EAGLE_IMAGE="${EAGLE_IMAGE}"
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS:-shardok:40042}"
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
export SENTRY_DSN="${SENTRY_DSN}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
fi
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"GH_OAUTH_CLIENT_ID=\${GH_OAUTH_CLIENT_ID}" \
"GH_OAUTH_CLIENT_SECRET=\${GH_OAUTH_CLIENT_SECRET}" \
"APPLE_SIGNIN_CLIENT_ID=\${APPLE_SIGNIN_CLIENT_ID}" \
"APPLE_TEAM_ID=\${APPLE_TEAM_ID}" \
"APPLE_SIGNIN_KEY_ID=\${APPLE_SIGNIN_KEY_ID}" \
"APPLE_SIGNIN_PRIVATE_KEY=\${APPLE_SIGNIN_PRIVATE_KEY}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
+7 -3
View File
@@ -44,15 +44,19 @@ for arg in "$@"; do
fi
# Remove existing line for this key and add new one
# Values are quoted to handle special characters (/, =, etc.)
# Use single quotes to avoid issues with special characters in values
# (JSON strings, base64 with /, etc.)
# Escape any single quotes in the value by replacing ' with '\''
ESCAPED_VALUE="${VALUE//\'/\'\\\'\'}"
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, remove old line and add new one (sed with special chars is fragile)
grep -v "^${KEY}=" "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
echo "${KEY}=\"${VALUE}\"" >> "$ENV_FILE"
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=\"${VALUE}\"" >> "$ENV_FILE"
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
+10
View File
@@ -46,6 +46,11 @@ http {
root /var/www/certbot;
}
# Apple domain verification
location /.well-known/apple-developer-domain-association.txt {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
@@ -126,6 +131,11 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Apple domain verification (for Sign In with Apple)
location /.well-known/apple-developer-domain-association.txt {
root /var/www/certbot;
}
# Health check endpoint
location /health {
access_log off;
@@ -8,6 +8,7 @@ import (
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -111,6 +112,8 @@ func (s *OAuthService) ExchangeAppleCode(config OAuthProviderConfig, code string
data.Set("code", code)
data.Set("redirect_uri", callbackURL)
log.Printf("Apple token exchange: client_id=%s redirect_uri=%s", config.ClientID, callbackURL)
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
@@ -180,6 +183,8 @@ func ParseAppleIDToken(idToken string) (*ProviderUserInfo, error) {
// HandleAppleCallback handles the Apple OAuth callback
// Note: Apple sends a POST request to the callback URL, not GET
func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Request) {
log.Printf("Apple callback received: method=%s", r.Method)
// Apple POSTs the callback data
if r.Method != "POST" {
// Fallback to GET for the state parameter only
@@ -188,17 +193,20 @@ func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Reques
// This might be an error redirect
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
log.Printf("Apple callback error (GET): %s", errorParam)
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
}
log.Printf("Apple callback rejected: not POST")
http.Error(w, "Apple Sign-In requires POST callback", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
log.Printf("Apple callback: failed to parse form: %v", err)
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
@@ -207,13 +215,17 @@ func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Reques
state := r.FormValue("state")
errorParam := r.FormValue("error")
log.Printf("Apple callback: state=%s hasCode=%v error=%s", state, code != "", errorParam)
if errorParam != "" {
log.Printf("Apple callback error (POST): %s", errorParam)
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
if code == "" || state == "" {
log.Printf("Apple callback: missing code or state")
http.Error(w, "Missing code or state", http.StatusBadRequest)
return
}
@@ -221,6 +233,7 @@ func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Reques
// Get pending OAuth info
stateData, ok := s.pendingOAuth.Load(state)
if !ok {
log.Printf("Apple callback: state not found: %s", state)
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
@@ -237,6 +250,7 @@ func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Reques
config := s.configs[oauthState.Provider]
tokenResp, err := s.ExchangeAppleCode(config, code)
if err != nil {
log.Printf("Apple token exchange failed: %v", err)
s.completeWithError(state, err.Error())
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
+64 -1
View File
@@ -340,7 +340,70 @@ func (s *OAuthService) fetchUserInfo(provider string, config OAuthProviderConfig
return nil, fmt.Errorf("user info fetch failed: %d - %s", resp.StatusCode, string(body))
}
return s.parseUserInfo(provider, body)
userInfo, err := s.parseUserInfo(provider, body)
if err != nil {
return nil, err
}
// GitHub: fetch email from /user/emails if not in main response
// (GitHub only returns email in /user if user has made it public)
if provider == "github" && userInfo.Email == "" {
email, err := s.fetchGitHubEmail(accessToken)
if err != nil {
log.Printf("GitHub email fetch failed (non-fatal): %v", err)
} else {
userInfo.Email = email
}
}
return userInfo, nil
}
func (s *OAuthService) fetchGitHubEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("email fetch failed: %d", resp.StatusCode)
}
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.Unmarshal(body, &emails); err != nil {
return "", err
}
// Return primary verified email, or first verified email
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", fmt.Errorf("no verified email found")
}
func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUserInfo, error) {