add centralized error handling and improve Google Auth error logging

This commit is contained in:
bobbert committed 2026-02-24 23:23:53 +00:00
1 parent 0ef59ba5d3
commit f6c61ab829
5 files changed
+175 -161

No files matched your search

+107 -138
View File
@@ -2,6 +2,7 @@ import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as Application from 'expo-application';
import { AuthServiceBase } from './AuthServiceBase';
import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession();
@@ -24,7 +25,6 @@ const SCOPES = [
'https://www.googleapis.com/auth/photoslibrary',
];
// Redirect URI uses the Android package name as scheme, matching what
// expo-auth-session/providers/google generates for installed Android apps.
// Result: com.bobthebob.massphotoapp:/oauthredirect
@@ -37,110 +37,85 @@ class GoogleAUTH extends AuthServiceBase {
this.refreshToken = null;
this.expiresAt = null;
this.email = null;
this.idToken = null;
}
async authenticate() {
try {
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
console.warn('Google Auth: no Android client ID — using placeholder mode');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000;
this.email = 'google-user@placeholder.com';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
}
console.log('OAuth redirect URI:', REDIRECT_URI);
console.log('Requesting scopes:', SCOPES.join(' '));
// Auth request uses the Android client ID.
// Android OAuth clients are verified by package name + SHA-1 signing cert,
// so Google accepts the package-name scheme redirect without URI registration.
//
// NOTE: Do NOT use include_granted_scopes — we need a clean token with
// exactly the scopes listed above. Old tokens may carry stale scopes
// (e.g. photoslibrary instead of photoslibrary.readonly) that no longer
// work with the Photos Library API.
const authRequest = new AuthSession.AuthRequest({
clientId: ANDROID_CLIENT_ID,
scopes: SCOPES,
redirectUri: REDIRECT_URI,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
extraParams: {
access_type: 'offline',
prompt: 'consent',
},
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
if (result.type !== 'success') {
throw new Error('Google authentication was cancelled or failed');
}
// Exchange authorization code for tokens using the Android client ID.
// Android clients are public (no client secret required) — the PKCE
// code_verifier is used to verify the exchange instead.
const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
code: result.params.code,
code_verifier: authRequest.codeVerifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
});
const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody.toString(),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
throw new Error(tokenData.error_description || tokenData.error);
}
this.accessToken = tokenData.access_token;
this.refreshToken = tokenData.refresh_token || null;
this.idToken = tokenData.id_token || null;
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
// Log the scopes Google actually granted (the token response includes a `scope` field)
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
// Also verify via tokeninfo endpoint for a definitive answer
try {
const infoRes = await fetch(
`https://oauth2.googleapis.com/tokeninfo?access_token=${this.accessToken}`
);
const infoData = await infoRes.json();
console.log('Google tokeninfo scopes:', infoData.scope || '(none)');
console.log('Google tokeninfo audience:', infoData.aud || '(none)');
} catch (e) {
console.log('Could not fetch tokeninfo:', e.message);
}
const userInfo = await this.fetchUserInfo();
this.email = userInfo?.email || 'google-user';
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
console.warn('Google Auth: no Android client ID — using placeholder mode');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000;
this.email = 'google-user@placeholder.com';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
} catch (error) {
console.error('Google authentication error:', error);
throw error;
}
console.log('OAuth redirect URI:', REDIRECT_URI);
console.log('Requesting scopes:', SCOPES.join(' '));
const authRequest = new AuthSession.AuthRequest({
clientId: ANDROID_CLIENT_ID,
scopes: SCOPES,
redirectUri: REDIRECT_URI,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
extraParams: {
access_type: 'offline',
prompt: 'consent',
},
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
if (result.type !== 'success') {
const err = new Error('Google authentication was cancelled or failed');
logError('Google Auth', err);
throw err;
}
// Exchange authorization code for tokens.
// Android clients are public — PKCE code_verifier secures the exchange.
const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
code: result.params.code,
code_verifier: authRequest.codeVerifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
});
const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody.toString(),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
const err = new Error(tokenData.error_description || tokenData.error);
logError('Google Token Exchange', err);
throw err;
}
this.accessToken = tokenData.access_token;
this.refreshToken = tokenData.refresh_token || null;
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
const userInfo = await this.fetchUserInfo();
this.email = userInfo?.email || 'google-user';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
}
async fetchUserInfo() {
@@ -162,29 +137,28 @@ class GoogleAUTH extends AuthServiceBase {
}
async refreshAccessToken() {
try {
const body = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
});
const body = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
});
const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = await response.json();
if (data.error) throw new Error(data.error_description || data.error);
this.accessToken = data.access_token;
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
return this.accessToken;
} catch (error) {
console.error('Token refresh error:', error);
throw error;
const data = await response.json();
if (data.error) {
const err = new Error(data.error_description || data.error);
logError('Token Refresh', err);
throw err;
}
this.accessToken = data.access_token;
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
return this.accessToken;
}
async revokeAccess() {
@@ -195,13 +169,12 @@ class GoogleAUTH extends AuthServiceBase {
});
}
} catch (err) {
console.error('Revoke error:', err);
logError('Revoke', err);
}
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
this.email = null;
this.idToken = null;
return { success: true };
}
@@ -216,7 +189,6 @@ class GoogleAUTH extends AuthServiceBase {
// Use the mediaItems:search endpoint (POST) instead of the deprecated
// GET /v1/mediaItems which Google shut down in 2025.
// An empty filters body returns ALL photos, ordered by creation time.
const body = { pageSize };
if (pageToken) body.pageToken = pageToken;
@@ -242,23 +214,16 @@ class GoogleAUTH extends AuthServiceBase {
errorJson?.error?.message || errorBody || `HTTP ${response.status}`;
const googleStatus = errorJson?.error?.status || '';
console.error(
`Google Photos API ${response.status} (${googleStatus}):`,
googleMessage,
);
logError('Google Photos API', `${response.status} (${googleStatus}): ${googleMessage}`);
// 401 → token expired, try one refresh
if (response.status === 401 && this.refreshToken && !pageToken) {
console.log('Token expired, refreshing and retrying…');
await this.refreshAccessToken();
return this.getPhotos(pageSize, pageToken);
}
// 403 → scope or API issue
if (response.status === 403) {
const isScopeProblem =
googleMessage.toLowerCase().includes('scope');
const isScopeProblem = googleMessage.toLowerCase().includes('scope');
return {
mediaItems: [],
nextPageToken: null,
@@ -292,7 +257,7 @@ class GoogleAUTH extends AuthServiceBase {
nextPageToken: data.nextPageToken || null,
};
} catch (error) {
console.error('Get photos error:', error);
logError('Get Photos', error);
return { mediaItems: [], nextPageToken: null };
}
}
@@ -310,21 +275,19 @@ class GoogleAUTH extends AuthServiceBase {
if (!response.ok) {
const errorBody = await response.text();
console.error(`Google Albums API error ${response.status}:`, errorBody);
logError('Google Albums API', `${response.status}: ${errorBody}`);
return { albums: [], nextPageToken: null };
}
const data = await response.json();
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
} catch (error) {
console.error('Get albums error:', error);
logError('Get Albums', error);
return { albums: [], nextPageToken: null };
}
}
// NOTE: Upload requires the `photoslibrary.appendonly` scope which is not
// requested by default (read-only mode). Re-authenticate with upload scopes
// before calling this method.
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') {
// NOTE: Upload requires the `photoslibrary.appendonly` scope.
async uploadPhoto(filePath, filename) {
try {
const token = await this.getAccessToken();
@@ -339,7 +302,10 @@ class GoogleAUTH extends AuthServiceBase {
body: await fetch(filePath).then((r) => r.blob()),
});
if (!uploadResponse.ok) throw new Error('Upload bytes failed');
if (!uploadResponse.ok) {
logError('Upload Photo', 'Upload bytes failed: HTTP ' + uploadResponse.status);
return null;
}
const uploadToken = await uploadResponse.text();
const createResponse = await fetch(
@@ -353,11 +319,14 @@ class GoogleAUTH extends AuthServiceBase {
}
);
if (!createResponse.ok) throw new Error('Create media item failed');
if (!createResponse.ok) {
logError('Upload Photo', 'Create media item failed: HTTP ' + createResponse.status);
return null;
}
return await createResponse.json();
} catch (error) {
console.error('Upload photo error:', error);
throw error;
logError('Upload Photo', error);
return null;
}
}
}