Skip to content

Commit d3d9213

Browse files
committed
feat: osm auth for web client
1 parent 1857317 commit d3d9213

File tree

4 files changed

+150
-3
lines changed

4 files changed

+150
-3
lines changed

docker-compose.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,23 @@ services:
230230
OSM_OAUTH_API_URL: '${OSM_OAUTH_API_URL}'
231231
OSM_OAUTH_CLIENT_ID: '${OSM_OAUTH_CLIENT_ID}'
232232
OSM_OAUTH_CLIENT_SECRET: '${OSM_OAUTH_CLIENT_SECRET}'
233+
OSM_OAUTH_REDIRECT_URI_WEB: '${OSM_OAUTH_REDIRECT_URI_WEB}'
234+
OSM_OAUTH_APP_LOGIN_LINK_WEB: '${OSM_APP_LOGIN_LINK_WEB}'
235+
OSM_OAUTH_CLIENT_ID_WEB: '${OSM_OAUTH_CLIENT_ID_WEB}'
236+
OSM_OAUTH_CLIENT_SECRET_WEB: '${OSM_OAUTH_CLIENT_SECRET_WEB}'
233237
command: >-
234238
sh -c "firebase use $FIREBASE_DB &&
235239
firebase target:apply hosting auth \"$FIREBASE_AUTH_SITE\" &&
236240
firebase functions:config:set
237241
osm.redirect_uri=\"$OSM_OAUTH_REDIRECT_URI\"
242+
osm.redirect_uri_web=\"$OSM_OAUTH_REDIRECT_URI_WEB\"
238243
osm.app_login_link=\"$OSM_OAUTH_APP_LOGIN_LINK\"
244+
osm.app_login_link_web=\"$OSM_OAUTH_APP_LOGIN_LINK_WEB\"
239245
osm.api_url=\"$OSM_OAUTH_API_URL\"
240246
osm.client_id=\"$OSM_OAUTH_CLIENT_ID\"
241-
osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\" &&
247+
osm.client_id_web=\"$OSM_OAUTH_CLIENT_ID_WEB\"
248+
osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\"
249+
osm.client_secret_web=\"$OSM_OAUTH_CLIENT_SECRET_WEB\" &&
242250
firebase deploy --token $FIREBASE_TOKEN --only functions,hosting,database"
243251
244252
django:

example.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,15 @@ OSMCHA_API_KEY=
3838

3939
# OSM OAuth Configuration
4040
OSM_OAUTH_REDIRECT_URI=
41+
OSM_OAUTH_REDIRECT_URI_WEB=
4142
OSM_OAUTH_API_URL=
4243
OSM_OAUTH_CLIENT_ID=
44+
OSM_OAUTH_CLIENT_ID_WEB=
4345
OSM_OAUTH_CLIENT_SECRET=
46+
OSM_OAUTH_CLIENT_SECRET_WEB=
4447
OSM_APP_LOGIN_LINK=
48+
OSM_APP_LOGIN_LINK_WEB=
49+
4550

4651
# DJANGO For more info look at django/mapswipe/settings.py::L22
4752
DJANGO_SECRET_KEY=

firebase/functions/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ admin.initializeApp();
88
// all functions are bundled together. It's less than ideal, but it does not
99
// seem possible to split them using the split system for multiple sites from
1010
// https://firebase.google.com/docs/hosting/multisites
11-
import {redirect, token} from './osm_auth';
11+
import {redirect, token, redirect_web, token_web} from './osm_auth';
1212
import { formatProjectTopic, formatUserName } from './utils';
1313

1414
exports.osmAuth = {};
@@ -23,6 +23,14 @@ exports.osmAuth.token = functions.https.onRequest((req, res) => {
2323
token(req, res, admin);
2424
});
2525

26+
exports.osmAuth.redirect_web = functions.https.onRequest((req, res) => {
27+
redirect_web(req, res);
28+
});
29+
30+
exports.osmAuth.token_web = functions.https.onRequest((req, res) => {
31+
token_web(req, res, admin);
32+
});
33+
2634
/*
2735
Log the userIds of all users who finished a group to /v2/userGroups/{projectId}/{groupId}/.
2836
Gets triggered when new results of a group are written to the database.

firebase/functions/src/osm_auth.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Firebase cloud functions to allow authentication with OpenStreet Map
1+
// Firebase cloud functions to allow authentication with OpenStreetMap
22
//
33
// There are really 2 functions, which must be publicly accessible via
44
// an https endpoint. They can be hosted on firebase under a domain like
@@ -20,8 +20,10 @@ import axios from 'axios';
2020
// will get a cryptic error about the server not being able to continue
2121
// TODO: adjust the prefix based on which deployment is done (prod/dev)
2222
const OAUTH_REDIRECT_URI = functions.config().osm?.redirect_uri;
23+
const OAUTH_REDIRECT_URI_WEB = functions.config().osm?.redirect_web;
2324

2425
const APP_OSM_LOGIN_DEEPLINK = functions.config().osm?.app_login_link;
26+
const APP_OSM_LOGIN_DEEPLINK_WEB = functions.config().osm?.app_login_link_web;
2527

2628
// the scope is taken from https://wiki.openstreetmap.org/wiki/OAuth#OAuth_2.0
2729
// at least one seems to be required for the auth workflow to complete.
@@ -51,6 +53,21 @@ function osmOAuth2Client() {
5153
return simpleOAuth2.create(credentials);
5254
}
5355

56+
function osmOAuth2ClientWeb() {
57+
const credentials = {
58+
client: {
59+
id: functions.config().osm?.client_id_web,
60+
secret: functions.config().osm?.client_secret_web,
61+
},
62+
auth: {
63+
tokenHost: OSM_API_URL,
64+
tokenPath: '/oauth2/token',
65+
authorizePath: '/oauth2/authorize',
66+
},
67+
};
68+
return simpleOAuth2.create(credentials);
69+
}
70+
5471
/**
5572
* Redirects the User to the OSM authentication consent screen.
5673
* Also the '__session' cookie is set for later state verification.
@@ -84,6 +101,32 @@ export const redirect = (req: any, res: any) => {
84101
});
85102
};
86103

104+
export const redirect_web = (req: any, res: any) => {
105+
const oauth2 = osmOAuth2ClientWeb();
106+
107+
cookieParser()(req, res, () => {
108+
const state =
109+
req.cookies.state || crypto.randomBytes(20).toString('hex');
110+
functions.logger.log('Setting verification state:', state);
111+
// the cookie MUST be called __session for hosted functions not to
112+
// strip it from incoming requests
113+
// (https://firebase.google.com/docs/hosting/manage-cache#using_cookies)
114+
res.cookie('__session', state.toString(), {
115+
// cookie is valid for 1 hour
116+
maxAge: 3600000,
117+
secure: true,
118+
httpOnly: true,
119+
});
120+
const redirectUri = oauth2.authorizationCode.authorizeURL({
121+
redirect_uri: OAUTH_REDIRECT_URI_WEB,
122+
scope: OAUTH_SCOPES,
123+
state: state,
124+
});
125+
functions.logger.log('Redirecting to:', redirectUri);
126+
res.redirect(redirectUri);
127+
});
128+
};
129+
87130
/**
88131
* The OSM OAuth endpoing does not give us any info about the user,
89132
* so we need to get the user profile from this endpoint
@@ -189,6 +232,89 @@ export const token = async (req: any, res: any, admin: any) => {
189232
}
190233
};
191234

235+
236+
export const token_web = async (req: any, res: any, admin: any) => {
237+
const oauth2 = osmOAuth2ClientWeb();
238+
239+
try {
240+
return cookieParser()(req, res, async () => {
241+
functions.logger.log(
242+
'Received verification state:',
243+
req.cookies.__session,
244+
);
245+
functions.logger.log('Received state:', req.query.state);
246+
// FIXME: For security, we need to check the cookie that was set
247+
// in the /redirect_web function on the user's browser.
248+
// However, there seems to be a bug in firebase around this.
249+
// https://github.com/firebase/firebase-functions/issues/544
250+
// and linked SO question
251+
// firebase docs mention the need for a cookie middleware, but there
252+
// is no info about it :(
253+
// cross site cookies don't seem to be the issue
254+
// WE just need to make sure the domain set on the cookies is right
255+
if (!req.cookies.__session) {
256+
throw new Error('State cookie not set or expired. Maybe you took too long to authorize. Please try again.');
257+
} else if (req.cookies.__session !== req.query.state) {
258+
throw new Error('State validation failed');
259+
}
260+
functions.logger.log('Received auth code:', req.query.code);
261+
let results;
262+
263+
try {
264+
// TODO: try adding auth data to request headers if
265+
// this doesn't work
266+
results = await oauth2.authorizationCode.getToken({
267+
code: req.query.code,
268+
redirect_uri: OAUTH_REDIRECT_URI,
269+
scope: OAUTH_SCOPES,
270+
state: req.query.state,
271+
});
272+
} catch (error: any) {
273+
functions.logger.log('Auth token error', error, error.data.res.req);
274+
}
275+
// why is token called twice?
276+
functions.logger.log(
277+
'Auth code exchange result received:',
278+
results,
279+
);
280+
281+
// We have an OSM access token and the user identity now.
282+
const accessToken = results && results.access_token;
283+
if (accessToken === undefined) {
284+
throw new Error(
285+
'Could not get an access token from OpenStreetMap',
286+
);
287+
}
288+
// get the OSM user id and display_name
289+
const { id, display_name } = await getOSMProfile(accessToken);
290+
functions.logger.log('osmuser:', id, display_name);
291+
if (id === undefined) {
292+
// this should not happen, but help guard against creating
293+
// invalid accounts
294+
throw new Error('Could not obtain an account id from OSM');
295+
}
296+
297+
// Create a Firebase account and get the Custom Auth Token.
298+
const firebaseToken = await createFirebaseAccount(
299+
admin,
300+
id,
301+
display_name,
302+
accessToken,
303+
);
304+
// build a deep link so we can send the token back to the app
305+
// from the browser
306+
const signinUrl = `${APP_OSM_LOGIN_DEEPLINK_WEB}?token=${firebaseToken}`;
307+
functions.logger.log('redirecting user to', signinUrl);
308+
res.redirect(signinUrl);
309+
});
310+
} catch (error: any) {
311+
// FIXME: this should show up in the user's browser as a bit of text
312+
// We should figure out the various error codes available and feed them
313+
// back into the app to allow the user to take action
314+
return res.json({ error: error.toString() });
315+
}
316+
};
317+
192318
/**
193319
* Creates a Firebase account with the given user profile and returns a custom
194320
* auth token allowing the user to sign in to this account on the app.

0 commit comments

Comments
 (0)