frontend,api: Expose authorization server metadata and use it
This commit is contained in:
parent
6ba29e68a0
commit
5ce2947fea
|
@ -284,4 +284,29 @@ router.get(
|
|||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Metadata endpoint to inform clients about authorization server capabilities,
|
||||
* according to https://tools.ietf.org/html/rfc8414.
|
||||
*/
|
||||
router.get(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
wrapRoute(async (req, res) => {
|
||||
const baseUrl = 'http://localhost:3000';
|
||||
|
||||
return res.json({
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/authorize`,
|
||||
token_endpoint: `${baseUrl}/token`,
|
||||
token_endpoint_auth_methods_supported: ['none'], // only public clients
|
||||
userinfo_endpoint: `${baseUrl}/api/user`,
|
||||
// registration_endpoint: `${baseUrl}/register`, // TODO
|
||||
// scopes_supported: ALL_SCOPE_NAMES, // TODO
|
||||
response_types_supported: ['code'], // only auth code, no implicit flow or
|
||||
service_documentation: 'https://github.com/openbikesensor/portal',
|
||||
ui_locales_supported: ['en-US', 'en-GB', 'en-CA', 'fr-FR', 'fr-CA'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
@ -10,6 +10,51 @@ class API {
|
|||
this._getValidAccessTokenPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches or directly returns from cache the metadata information from the
|
||||
* authorization server, according to https://tools.ietf.org/html/rfc8414.
|
||||
* Also validates compatibility with this metadata server, i.e. checking that
|
||||
* it supports PKCE.
|
||||
*/
|
||||
async getAuthorizationServerMetadata() {
|
||||
const url = new URL(config.auth.server)
|
||||
const pathSuffix = url.pathname.replace(/^\/+|\/+$/, '')
|
||||
url.pathname = '/.well-known/oauth-authorization-server' + (pathSuffix ? '/' + pathSuffix : '')
|
||||
|
||||
const response = await window.fetch(url.toString())
|
||||
const metadata = await response.json()
|
||||
|
||||
const {authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint,
|
||||
response_types_supported: responseTypesSupported,
|
||||
code_challenge_methods_supported: codeChallengeMethodsSupported,
|
||||
} = metadata
|
||||
if (!authorizationEndpoint) {
|
||||
throw new Error('No authorization endpoint');
|
||||
}
|
||||
|
||||
if (!authorizationEndpoint.startsWith(config.auth.server)) {
|
||||
throw new Error('Invalid authorization endpoint');
|
||||
}
|
||||
|
||||
if (!tokenEndpoint) {
|
||||
throw new Error('No token endpoint');
|
||||
}
|
||||
|
||||
if (!tokenEndpoint.startsWith(config.auth.server)) {
|
||||
throw new Error('Invalid token endpoint');
|
||||
}
|
||||
|
||||
if (!Array.isArray(responseTypesSupported) || !responseTypesSupported.includes('code')) {
|
||||
throw new Error('Authorization code flow not supported or no support advertised.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(codeChallengeMethodsSupported) || !codeChallengeMethodsSupported.includes('S256')) {
|
||||
throw new Error('PKCE with S256 not supported or no support advertised.');
|
||||
}
|
||||
|
||||
return {authorizationEndpoint, tokenEndpoint}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an access token, if it is (still) valid. If not, and a refresh
|
||||
* token exists, use the refresh token to issue a new access token. If that
|
||||
|
@ -47,7 +92,8 @@ class API {
|
|||
}
|
||||
|
||||
// Try to use the refresh token
|
||||
const url = new URL(config.auth.tokenEndpoint)
|
||||
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
|
||||
const url = new URL(tokenEndpoint)
|
||||
url.searchParams.append('refresh_token', refreshToken)
|
||||
url.searchParams.append('grant_type', 'refresh_token')
|
||||
url.searchParams.append('scope', config.auth.scope)
|
||||
|
@ -66,7 +112,8 @@ class API {
|
|||
}
|
||||
|
||||
async exchangeAuthorizationCode(code) {
|
||||
const url = new URL(config.auth.tokenEndpoint)
|
||||
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
|
||||
const url = new URL(tokenEndpoint)
|
||||
url.searchParams.append('code', code)
|
||||
url.searchParams.append('grant_type', 'authorization_code')
|
||||
url.searchParams.append('client_id', config.auth.clientId)
|
||||
|
@ -87,8 +134,10 @@ class API {
|
|||
return true
|
||||
}
|
||||
|
||||
getLoginUrl() {
|
||||
const loginUrl = new URL(config.auth.authorizationEndpoint)
|
||||
async makeLoginUrl() {
|
||||
const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
|
||||
|
||||
const loginUrl = new URL(authorizationEndpoint)
|
||||
loginUrl.searchParams.append('client_id', config.auth.clientId)
|
||||
loginUrl.searchParams.append('scope', config.auth.scope)
|
||||
loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import React from 'react'
|
||||
import {Button} from 'semantic-ui-react'
|
||||
|
||||
import api from 'api'
|
||||
|
||||
export default function LoginButton(props) {
|
||||
// TODO: Implement PKCE, generate login URL when clicked (with challenge),
|
||||
// and then redirect there.
|
||||
const href = api.getLoginUrl()
|
||||
return <Button as='a' href={href} {...props}>Login</Button>
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
|
||||
const onClick = React.useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
const url = await api.makeLoginUrl()
|
||||
window.location.href = url
|
||||
setBusy(false)
|
||||
}, [setBusy])
|
||||
|
||||
return <Button onClick={busy ? null : onClick} loading={busy} {...props}>Login</Button>
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"auth": {
|
||||
"authorizationEndpoint": "http://localhost:3000/authorize",
|
||||
"tokenEndpoint": "http://localhost:3000/token",
|
||||
"server": "http://localhost:3000",
|
||||
"clientId": "123",
|
||||
"scope": "*",
|
||||
"redirectUri": "http://localhost:3001/redirect"
|
||||
|
|
Loading…
Reference in a new issue