-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: express example #50
Changes from 29 commits
f288c4b
4da9c8d
e01e1f6
548b862
7736b05
a620203
d2b4a89
8f8d830
e3a68f0
897e57f
620a0f8
af44a5c
f2d9bdc
e030880
fbc0654
0752907
dfc592b
bc86541
d440639
c6d3c08
4e0c566
fe0236b
306ac8d
9b6f35a
ca6c50a
bf5470a
930c290
aca2c32
a0992a4
f17059f
beef837
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
SGID_CLIENT_ID="Your client ID" | ||
SGID_CLIENT_SECRET="Your client secret" | ||
SGID_PRIVATE_KEY="Your private key" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
module.exports = { | ||
root: true, | ||
env: { | ||
commonjs: true, | ||
es2021: true, | ||
node: true, | ||
}, | ||
extends: ['standard-with-typescript', 'plugin:prettier/recommended'], | ||
overrides: [], | ||
parserOptions: { | ||
ecmaVersion: 'latest', | ||
project: 'examples/express/tsconfig.json', | ||
}, | ||
rules: { | ||
'@typescript-eslint/no-misused-promises': 'off', | ||
}, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dist/* | ||
.env | ||
node_modules/ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"jsc": { | ||
"parser": { | ||
"syntax": "typescript", | ||
"tsx": false | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
# ExpressJS example sgID app | ||
|
||
The example application code is in `index.ts`. You can copy this code to bootstrap your sgID client application, and run this app locally to understand how this SDK helps you interact with the sgID server. | ||
|
||
## Running this example app locally | ||
|
||
### Prerequisites | ||
|
||
Register a new client at the [sgID developer portal](https://developer.id.gov.sg). For detailed instructions on how to register, check out our [documentation](https://docs.id.gov.sg/introduction/getting-started/register-your-application). Feel free to register a test client; there is no limit to the number of clients you can create. | ||
|
||
When registering a client, be sure to register the callback URL to be `http://localhost:5001/api/callback`, since this example runs on port `5001` by default. | ||
|
||
### Steps to run locally | ||
|
||
1. Clone this repo. | ||
|
||
``` | ||
git clone https://github.com/opengovsg/sgid-client.git | ||
``` | ||
|
||
2. Go to this folder and copy the contents of `.env.example` into a new file called `.env`. | ||
|
||
``` | ||
cd sgid-client/examples/express | ||
cat .env.example > .env | ||
``` | ||
|
||
3. Replace the values in `.env` with the credentials of your sgID client (see [Prerequisites](#prerequisites)). | ||
|
||
4. Run the Express app: | ||
|
||
``` | ||
npm install | ||
npm start | ||
``` | ||
|
||
This should start the server on port `5001` by default | ||
|
||
5. Clone sgID's example frontend repo: | ||
|
||
``` | ||
git clone https://github.com/opengovsg/sgid-demo-frontend-spa.git | ||
``` | ||
|
||
6. Install and run the example frontend: | ||
kwajiehao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
``` | ||
cd sgid-demo-frontend-spa | ||
npm install | ||
npm run dev | ||
``` | ||
|
||
This should start the frontend on port `5173` by default. Visit `http://localhost:5173` to test the entire sgID flow! | ||
|
||
## For contributors | ||
|
||
To start the server in debug mode, run: | ||
|
||
``` | ||
npm install | ||
npm run dev | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
import express, { Router } from 'express' | ||
import cors from 'cors' | ||
import SgidClient, { generatePkcePair } from '@opengovsg/sgid-client' | ||
import * as dotenv from 'dotenv' | ||
import crypto from 'crypto' | ||
import cookieParser from 'cookie-parser' | ||
import open from 'open' | ||
|
||
dotenv.config() | ||
|
||
const PORT = 5001 | ||
const redirectUri = String( | ||
process.env.SGID_REDIRECT_URI ?? `http://localhost:${PORT}/api/callback`, | ||
) | ||
const frontendHost = String( | ||
process.env.SGID_FRONTEND_HOST ?? 'http://localhost:5173', | ||
) | ||
|
||
const sgid = new SgidClient({ | ||
clientId: String(process.env.SGID_CLIENT_ID), | ||
clientSecret: String(process.env.SGID_CLIENT_SECRET), | ||
privateKey: String(process.env.SGID_PRIVATE_KEY), | ||
redirectUri, | ||
}) | ||
|
||
const app = express() | ||
|
||
const apiRouter = Router() | ||
|
||
const SESSION_COOKIE_NAME = 'exampleAppSession' | ||
const SESSION_COOKIE_OPTIONS = { | ||
httpOnly: true, | ||
} | ||
|
||
type SessionData = Record< | ||
string, | ||
| { | ||
nonce?: string | ||
// Store state as search params to easily stringify key-value pairs | ||
state?: URLSearchParams | ||
accessToken?: string | ||
codeVerifier?: string | ||
sub?: string | ||
} | ||
| undefined | ||
> | ||
|
||
/** | ||
* In-memory store for session data. | ||
* In a real application, this would be a database. | ||
*/ | ||
const sessionData: SessionData = {} | ||
|
||
app.use( | ||
cors({ | ||
credentials: true, | ||
origin: frontendHost, | ||
}), | ||
) | ||
|
||
apiRouter.get('/auth-url', (req, res) => { | ||
const iceCreamSelection = String(req.query.icecream) | ||
const sessionId = crypto.randomUUID() | ||
// Use search params to store state so other key-value pairs | ||
// can be added easily | ||
const state = new URLSearchParams({ | ||
icecream: iceCreamSelection, | ||
}) | ||
|
||
// Generate a PKCE pair | ||
const { codeChallenge, codeVerifier } = generatePkcePair() | ||
|
||
const { url, nonce } = sgid.authorizationUrl({ | ||
// We pass the user's ice cream preference as the state, | ||
// so after they log in, we can display it together with the | ||
// other user info. | ||
state: state.toString(), | ||
codeChallenge, | ||
// Scopes that all sgID relying parties can access by default | ||
scope: ['openid', 'myinfo.name'], | ||
}) | ||
sessionData[sessionId] = { | ||
state, | ||
nonce, | ||
codeVerifier, | ||
} | ||
return res | ||
.cookie(SESSION_COOKIE_NAME, sessionId, SESSION_COOKIE_OPTIONS) | ||
.json({ url }) | ||
}) | ||
|
||
apiRouter.get('/callback', async (req, res): Promise<void> => { | ||
const authCode = String(req.query.code) | ||
const state = String(req.query.state) | ||
const sessionId = String(req.cookies[SESSION_COOKIE_NAME]) | ||
|
||
const session = { ...sessionData[sessionId] } | ||
// Validate that the state matches what we passed to sgID for this session | ||
if (session?.state?.toString() !== state) { | ||
res.redirect(`${frontendHost}/error`) | ||
return | ||
} | ||
|
||
// Validate that the code verifier exists for this session | ||
if (session?.codeVerifier === undefined) { | ||
res.redirect(`${frontendHost}/error`) | ||
return | ||
} | ||
|
||
// Exchange the authorization code and code verifier for the access token | ||
const { codeVerifier, nonce } = session | ||
const { accessToken, sub } = await sgid.callback({ | ||
code: authCode, | ||
nonce, | ||
codeVerifier, | ||
}) | ||
|
||
session.accessToken = accessToken | ||
session.sub = sub | ||
sessionData[sessionId] = session | ||
|
||
// Successful login, redirect to logged in state | ||
res.redirect(`${frontendHost}/logged-in`) | ||
}) | ||
|
||
apiRouter.get('/userinfo', async (req, res) => { | ||
const sessionId = String(req.cookies[SESSION_COOKIE_NAME]) | ||
|
||
// Retrieve the access token and sub | ||
const session = sessionData[sessionId] | ||
const accessToken = session?.accessToken | ||
const sub = session?.sub | ||
|
||
// User is not authenticated | ||
if (session === undefined || accessToken === undefined || sub === undefined) { | ||
return res.sendStatus(401) | ||
} | ||
const userinfo = await sgid.userinfo({ | ||
accessToken, | ||
sub, | ||
}) | ||
|
||
// Add ice cream flavour to userinfo | ||
userinfo.data.iceCream = session.state?.get('icecream') ?? 'None' | ||
return res.json(userinfo) | ||
}) | ||
|
||
apiRouter.get('/logout', async (req, res) => { | ||
const sessionId = String(req.cookies[SESSION_COOKIE_NAME]) | ||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if we have to disable this rule, should we consider either turning off the rule or using javascript There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
delete sessionData[sessionId] | ||
return res | ||
.clearCookie(SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS) | ||
.sendStatus(200) | ||
}) | ||
|
||
const initServer = async (): Promise<void> => { | ||
try { | ||
app.use(cookieParser()) | ||
app.use('/api', apiRouter) | ||
|
||
app.listen(PORT, () => { | ||
console.log(`Server listening on port ${PORT}`) | ||
void open(`http://localhost:${PORT}`) | ||
}) | ||
} catch (error) { | ||
console.error( | ||
kwajiehao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
'Something went wrong while starting the server. Please restart the server.', | ||
) | ||
console.error(error) | ||
} | ||
} | ||
|
||
void initServer() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need to add instructions here on how to run the frontend?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have it in our docs (https://docs.id.gov.sg/integrations-with-sgid/typescript-javascript/express-with-single-page-app-frontend#step-5-test-it-out) but let me add it here too
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated in ca6c50a