зеркало из https://github.com/mozilla/fxa.git
Коммит
cd1494af0c
|
@ -4,6 +4,6 @@
|
|||
"client_secret": "852ae8d050d6805a402272e0c776193cfba263ceaa5546dce837191be98db91e",
|
||||
"content_uri": "http://127.0.0.1:3030",
|
||||
"profile_uri": "http://127.0.0.1:1111/v1",
|
||||
"oauth_uri": "http://127.0.0.1:9010/v1"
|
||||
"oauth_uri": "http://127.0.0.1:9000/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ PATH=$PATH:$HOME/.cargo/bin
|
|||
# Now for concurrently!
|
||||
../node_modules/.bin/concurrently \
|
||||
"cd fxa-content-server; npm ci; cp server/config/local.json-dist server/config/local.json" \
|
||||
"cd fxa-auth-server; npm ci; node ./scripts/gen_keys.js; node ./scripts/gen_vapid_keys.js; node ./fxa-oauth-server/scripts/gen_keys; ../../_scripts/clone-authdb.sh" \
|
||||
"cd fxa-auth-server; npm ci; node ./scripts/gen_keys.js; NODE_ENV=dev ./scripts/oauth_gen_keys.js; node ./scripts/gen_vapid_keys.js; node ./fxa-oauth-server/scripts/gen_keys; ../../_scripts/clone-authdb.sh" \
|
||||
"cd fxa-auth-db-mysql; npm ci" \
|
||||
"cd browserid-verifier; npm ci" \
|
||||
"cd fxa-js-client; npm ci; npx grunt sjcl" \
|
||||
|
|
|
@ -46,18 +46,6 @@
|
|||
"max_restarts": "1",
|
||||
"min_uptime": "2m"
|
||||
},
|
||||
{
|
||||
"name": "oauth-server PORT 9010",
|
||||
"script": "../../../_scripts/oauth_mysql.sh",
|
||||
"cwd": "packages/fxa-auth-server/fxa-oauth-server",
|
||||
"env": {
|
||||
"NODE_ENV": "dev",
|
||||
"HOST": "0.0.0.0",
|
||||
"DB": "mysql"
|
||||
},
|
||||
"max_restarts": "1",
|
||||
"min_uptime": "2m"
|
||||
},
|
||||
{
|
||||
"name": "profile-server PORT 1111",
|
||||
"script": "../../_scripts/profile_mysql.sh",
|
||||
|
|
|
@ -107,18 +107,6 @@
|
|||
"max_restarts": "1",
|
||||
"min_uptime": "2m"
|
||||
},
|
||||
{
|
||||
"name": "oauth-server PORT 9010",
|
||||
"script": "../../../_scripts/oauth_mysql.sh",
|
||||
"cwd": "packages/fxa-auth-server/fxa-oauth-server",
|
||||
"env": {
|
||||
"NODE_ENV": "dev",
|
||||
"HOST": "0.0.0.0",
|
||||
"DB": "mysql"
|
||||
},
|
||||
"max_restarts": "1",
|
||||
"min_uptime": "2m"
|
||||
},
|
||||
{
|
||||
"name": "profile-server PORT 1111",
|
||||
"script": "../../_scripts/profile_mysql.sh",
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
/coverage.html
|
||||
/node_modules
|
||||
/sandbox
|
||||
/config/key.json
|
||||
/config/oldKey.json
|
||||
/config/public-key.json
|
||||
/config/secret-key.json
|
||||
/config/vapid-keys.json
|
||||
|
|
|
@ -1,49 +1,30 @@
|
|||
FROM node:10-alpine
|
||||
FROM node:10-slim
|
||||
|
||||
RUN npm install -g npm@6 && rm -rf ~app/.npm /tmp/*
|
||||
RUN set -x \
|
||||
&& addgroup --gid 10001 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--gid 10001 \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
|
||||
RUN apk add --no-cache git make gcc g++ linux-headers openssl python
|
||||
RUN apt-get update && apt-get -y install git-core python build-essential
|
||||
|
||||
RUN addgroup -g 10001 app && \
|
||||
adduser -D -G app -h /app -u 10001 app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=app:app fxa-auth-server /app
|
||||
COPY --chown=app:app ["fxa-geodb", "../fxa-geodb/"]
|
||||
COPY --chown=app:app ["fxa-shared", "../fxa-shared/"]
|
||||
|
||||
USER app
|
||||
|
||||
COPY fxa-auth-server/package-lock.json package-lock.json
|
||||
COPY fxa-auth-server/package.json package.json
|
||||
COPY fxa-auth-server/scripts/download_l10n.sh scripts/download_l10n.sh
|
||||
COPY fxa-auth-server/scripts/gen_keys.js scripts/gen_keys.js
|
||||
COPY fxa-auth-server/scripts/gen_vapid_keys.js scripts/gen_vapid_keys.js
|
||||
COPY fxa-auth-server/fxa-oauth-server/scripts/gen_keys.js fxa-oauth-server/scripts/gen_keys.js
|
||||
|
||||
RUN npm ci --production && rm -rf ~app/.npm /tmp/*
|
||||
|
||||
COPY fxa-auth-server /app
|
||||
|
||||
COPY ["fxa-geodb", "../fxa-geodb/"]
|
||||
WORKDIR /fxa-geodb
|
||||
USER root
|
||||
RUN npm ci
|
||||
|
||||
USER app
|
||||
COPY ["fxa-shared", "../fxa-shared/"]
|
||||
WORKDIR /fxa-shared
|
||||
USER root
|
||||
RUN npm ci
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown app:app /app/config
|
||||
RUN chown app:app /app/fxa-oauth-server/config
|
||||
|
||||
USER app
|
||||
RUN npm ci --production && rm -rf ~app/.npm /tmp/*
|
||||
RUN node scripts/gen_keys.js
|
||||
RUN NODE_ENV=dev node scripts/oauth_gen_keys.js
|
||||
RUN node scripts/gen_vapid_keys.js
|
||||
RUN node fxa-oauth-server/scripts/gen_keys.js
|
||||
|
||||
USER root
|
||||
RUN chown root:root /app/config
|
||||
|
||||
USER app
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
FROM fxa-auth-server:build
|
||||
|
||||
USER root
|
||||
RUN rm -rf /app/node_modules
|
||||
RUN rm -rf /app/fxa-content-server-l10n
|
||||
COPY fxa-auth-db-mysql fxa-auth-db-mysql
|
||||
RUN chown -R app /app
|
||||
|
||||
USER app
|
||||
RUN npm ci
|
||||
WORKDIR /app/fxa-auth-db-mysql
|
||||
RUN npm ci
|
||||
|
||||
# install dev dependencies
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
|
|
@ -26,6 +26,7 @@ function run(config) {
|
|||
}
|
||||
|
||||
const log = require('../lib/log')({ ...config.log, statsd });
|
||||
require('../lib/oauth/logging')(log);
|
||||
const getGeoData = require('../lib/geodb')(log);
|
||||
// Force the geo to load and run at startup, not waiting for it to run on
|
||||
// some route later.
|
||||
|
@ -163,6 +164,7 @@ function run(config) {
|
|||
)
|
||||
.then(() => {
|
||||
return {
|
||||
server,
|
||||
log: log,
|
||||
close() {
|
||||
return new P(resolve => {
|
||||
|
|
|
@ -148,5 +148,214 @@
|
|||
"statsd": {
|
||||
"enabled": true,
|
||||
"sampleRate": 1
|
||||
},
|
||||
"oauthServer": {
|
||||
"browserid": {
|
||||
"issuer": "127.0.0.1:9000",
|
||||
"verificationUrl": "http://127.0.0.1:5050/v2"
|
||||
},
|
||||
"contentUrl": "http://127.0.0.1:3030/oauth/",
|
||||
"clientManagement": {
|
||||
"enabled": true
|
||||
},
|
||||
"clientIdToServiceNames": {
|
||||
"dcdb5ae7add825d2": "123done",
|
||||
"98e6508e88680e1a": "fxa-settings"
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"id": "dcdb5ae7add825d2",
|
||||
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
|
||||
"name": "123Done",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "http://127.0.0.1:8080/api/oauth",
|
||||
"trusted": true,
|
||||
"canGrant": false
|
||||
},
|
||||
{
|
||||
"id": "38a6b9b3a65a1871",
|
||||
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
|
||||
"name": "123Done PKCE",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "http://127.0.0.1:8080/?oauth_pkce_redirect=1",
|
||||
"trusted": true,
|
||||
"canGrant": false,
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "22d74070a481bc73",
|
||||
"name": "Test Client iOS",
|
||||
"hashedSecret": "88716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec66",
|
||||
"redirectUri": "com.mozilla.sandvich:/oauth2redirect/fxa-provider",
|
||||
"imageUri": "",
|
||||
"publicClient": true,
|
||||
"canGrant": false,
|
||||
"termsUri": "",
|
||||
"privacyUri": "",
|
||||
"trusted": true,
|
||||
"allowedScopes": "https://identity.mozilla.com/apps/oldsync"
|
||||
},
|
||||
{
|
||||
"id": "325b4083e32fe8e7",
|
||||
"hashedSecret": "ded3c396f28123f3fe6b152784e8eab7357c6806cb5175805602a2cd67f85080",
|
||||
"name": "321Done Untrusted",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "http://127.0.0.1:10139/api/oauth",
|
||||
"trusted": false,
|
||||
"canGrant": false
|
||||
},
|
||||
{
|
||||
"id": "7f368c6886429f19",
|
||||
"name": "Firefox Notes Android Dev",
|
||||
"hashedSecret": "9c716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec3f",
|
||||
"redirectUri": "https://mozilla.github.io/notes/fxa/android-redirect.html",
|
||||
"imageUri": "",
|
||||
"canGrant": false,
|
||||
"termsUri": "",
|
||||
"privacyUri": "",
|
||||
"trusted": true,
|
||||
"allowedScopes": "https://identity.mozilla.com/apps/notes",
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "c6d74070a481bc10",
|
||||
"name": "Firefox Notes Dev",
|
||||
"hashedSecret": "9c716ed2927c96cdc0fb7efe57d5f124fb4161066c1ff7f4263069822256ec3f",
|
||||
"redirectUri": "https://dee85c67bd72f3de1f0a0fb62a8fe9b9b1a166d7.extensions.allizom.org/",
|
||||
"imageUri": "",
|
||||
"canGrant": false,
|
||||
"termsUri": "",
|
||||
"privacyUri": "",
|
||||
"trusted": true,
|
||||
"allowedScopes": "https://identity.mozilla.com/apps/notes",
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "98e6508e88680e1a",
|
||||
"hashedSecret": "ba5cfb370fd782f7eae1807443ab816288c101a54c0d80a09063273c86d3c435",
|
||||
"name": "Firefox Accounts Settings",
|
||||
"imageUri": "https://example2.domain/logo",
|
||||
"redirectUri": "https://example2.domain/return?foo=bar",
|
||||
"trusted": true,
|
||||
"canGrant": true,
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"name": "FxA OAuth Console",
|
||||
"redirectUri": "http://127.0.0.1:10137/oauth/redirect",
|
||||
"imageUri": "http://127.0.0.1:10137/assets/firefox.png",
|
||||
"id": "24bdbfa45cd300c5",
|
||||
"hashedSecret": "dfe56d5c816d6b7493618f6a1567cfed4aa9c25f85d59c6804631c48774ba545",
|
||||
"trusted": true,
|
||||
"canGrant": false
|
||||
},
|
||||
{
|
||||
"name": "Firefox",
|
||||
"id": "5882386c6d801776",
|
||||
"hashedSecret": "71b5283536f1f1c331eca2f75c58a5947d7a7ac54164eadb4b33a889afe89fbf",
|
||||
"imageUri": "",
|
||||
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
|
||||
"trusted": true,
|
||||
"canGrant": true
|
||||
},
|
||||
{
|
||||
"name": "Fennec",
|
||||
"id": "3332a18d142636cb",
|
||||
"hashedSecret": "99ee06fa07919c5208694d34d761fa95ee5a0bbbaad3f3ebaa6042b04a6bdec1",
|
||||
"imageUri": "",
|
||||
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
|
||||
"trusted": true,
|
||||
"canGrant": true
|
||||
},
|
||||
{
|
||||
"name": "Firefox Accounts",
|
||||
"id": "ea3ca969f8c6bb0d",
|
||||
"hashedSecret": "744559ea3d0f69eb5185cbd5b176a38e09d013c6459dbb3cbc25b4c5b165d33f",
|
||||
"imageUri": "",
|
||||
"redirectUri": "urn:ietf:wg:oauth:2.0:oob",
|
||||
"trusted": true,
|
||||
"canGrant": true,
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "3c49430b43dfba77",
|
||||
"name": "Android Components Reference Browser",
|
||||
"hashedSecret": "a7ee3482fab1782f5d3945cde06bb911605a8dfc1a45e4b77bc76615d5671e51",
|
||||
"imageUri": "",
|
||||
"redirectUri": "http://127.0.0.1:3030/oauth/success/3c49430b43dfba77",
|
||||
"canGrant": true,
|
||||
"trusted": true,
|
||||
"allowedScopes": "https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session",
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "a2270f727f45f648",
|
||||
"name": "Fenix",
|
||||
"hashedSecret": "4a892c55feaceb4ef2dbfffaaaa3d8eea94b5c205c815dddfc90170741cd4c19",
|
||||
"imageUri": "",
|
||||
"redirectUri": "http://127.0.0.1:3030/oauth/success/a2270f727f45f648",
|
||||
"canGrant": true,
|
||||
"trusted": true,
|
||||
"allowedScopes": "https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session",
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "59cceb6f8c32317c",
|
||||
"name": "Firefox Accounts Subscriptions",
|
||||
"hashedSecret": "220e560d48cf91dbba0219b986ca242a0b278eab8467bb07442fdfed1b245788",
|
||||
"redirectUri": "http://127.0.0.1:3031/",
|
||||
"imageUri": "",
|
||||
"canGrant": true,
|
||||
"termsUri": "",
|
||||
"privacyUri": "",
|
||||
"trusted": true,
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "d15ab1edd15ab1ed",
|
||||
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
|
||||
"name": "Disabled Client",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "http://127.0.0.1:8080/?oauth_pkce_redirect=1",
|
||||
"trusted": true,
|
||||
"canGrant": false,
|
||||
"publicClient": true
|
||||
}
|
||||
],
|
||||
"disabledClients": ["d15ab1edd15ab1ed"],
|
||||
"localRedirects": true,
|
||||
"jwtAccessTokens": {
|
||||
"enabled": true,
|
||||
"enabledClientIds": [
|
||||
"98e6508e88680e1a",
|
||||
"dcdb5ae7add825d2",
|
||||
"325b4083e32fe8e7"
|
||||
]
|
||||
},
|
||||
"openid": {
|
||||
"issuer": "http://127.0.0.1:3030",
|
||||
"keyFile": "config/key.json",
|
||||
"newKeyFile": "config/newKey.json",
|
||||
"oldKeyFile": "config/oldKey.json"
|
||||
},
|
||||
"ppid": {
|
||||
"enabled": true,
|
||||
"enabledClientIds": ["325b4083e32fe8e7"],
|
||||
"rotatingClientIds": ["325b4083e32fe8e7"],
|
||||
"rotationPeriodMS": 30000,
|
||||
"salt": "a new ppid salt"
|
||||
},
|
||||
"allowHttpRedirects": true,
|
||||
"authServerSecrets": ["megaz0rd", "whatever"],
|
||||
"scopes": [
|
||||
{
|
||||
"scope": "https://identity.mozilla.com/apps/notes",
|
||||
"hasScopedKeys": true
|
||||
},
|
||||
{
|
||||
"scope": "https://identity.mozilla.com/apps/oldsync",
|
||||
"hasScopedKeys": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -689,7 +689,7 @@ const conf = convict({
|
|||
url: {
|
||||
format: 'url',
|
||||
doc: 'URL at which to verify OAuth tokens',
|
||||
default: 'http://127.0.0.1:9010',
|
||||
default: 'http://127.0.0.1:9000',
|
||||
env: 'OAUTH_URL',
|
||||
},
|
||||
keepAlive: {
|
||||
|
@ -762,6 +762,381 @@ const conf = convict({
|
|||
},
|
||||
},
|
||||
},
|
||||
oauthServer: {
|
||||
admin: {
|
||||
whitelist: {
|
||||
doc: 'An array of regexes. Passing any one will get through.',
|
||||
default: ['@mozilla\\.com$'],
|
||||
},
|
||||
},
|
||||
api: {
|
||||
version: {
|
||||
doc: 'Number part of versioned endpoints - ex: /v1/token',
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
allowHttpRedirects: {
|
||||
arg: 'allowHttpRedirects',
|
||||
doc: 'If true, then it allows http OAuth redirect uris',
|
||||
env: 'ALLOW_HTTP_REDIRECTS',
|
||||
format: 'Boolean',
|
||||
default: false,
|
||||
},
|
||||
audience: {
|
||||
doc: 'audience for oauth JWTs',
|
||||
format: 'url',
|
||||
default: 'http://127.0.0.1:9000',
|
||||
env: 'OAUTH_URL',
|
||||
},
|
||||
auth: {
|
||||
poolee: {
|
||||
timeout: {
|
||||
default: '30 seconds',
|
||||
doc: 'Time in milliseconds to wait for auth server query completion',
|
||||
env: 'AUTH_POOLEE_TIMEOUT',
|
||||
format: 'duration',
|
||||
},
|
||||
maxPending: {
|
||||
default: 1000,
|
||||
doc: 'Number of pending requests to fxa-auth-server to allow',
|
||||
env: 'AUTH_POOLEE_MAX_PENDING',
|
||||
format: 'int',
|
||||
},
|
||||
},
|
||||
jwtSecretKey: {
|
||||
default: 'megaz0rd',
|
||||
doc: 'Shared secret for signing oauth-to-auth server JWT assertions',
|
||||
format: 'String',
|
||||
env: 'AUTH_SERVER_SHARED_SECRET',
|
||||
},
|
||||
url: {
|
||||
default: 'http://127.0.0.1:9000',
|
||||
doc: 'The auth-server public URL',
|
||||
env: 'AUTH_SERVER_URL',
|
||||
format: 'url',
|
||||
},
|
||||
},
|
||||
authServerSecrets: {
|
||||
doc:
|
||||
'Comma-separated list of secret keys for verifying server-to-server JWTs',
|
||||
env: 'AUTH_SERVER_SECRETS',
|
||||
format: 'Array',
|
||||
default: [],
|
||||
},
|
||||
browserid: {
|
||||
issuer: {
|
||||
doc: 'We only accept assertions from this issuer',
|
||||
env: 'ISSUER',
|
||||
default: 'api.accounts.firefox.com',
|
||||
},
|
||||
maxSockets: {
|
||||
doc: 'The maximum number of connections that the pool can use at once.',
|
||||
env: 'BROWSERID_MAX_SOCKETS',
|
||||
default: 10,
|
||||
},
|
||||
verificationUrl: {
|
||||
doc: 'URL to the remote verifier we will use for fxa-assertions',
|
||||
format: 'url',
|
||||
env: 'VERIFICATION_URL',
|
||||
default: 'https://verifier.accounts.firefox.com/v2',
|
||||
},
|
||||
},
|
||||
clients: {
|
||||
doc: 'Some pre-defined clients that will be inserted into the DB',
|
||||
env: 'OAUTH_CLIENTS',
|
||||
format: 'Array',
|
||||
default: [],
|
||||
},
|
||||
clientManagement: {
|
||||
enabled: {
|
||||
doc:
|
||||
'Enable client management in OAuth server routes. Do NOT set this to true in production.',
|
||||
default: false,
|
||||
format: 'Boolean',
|
||||
env: 'CLIENT_MANAGEMENT_ENABLED',
|
||||
},
|
||||
},
|
||||
clientIdToServiceNames: {
|
||||
doc:
|
||||
'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
|
||||
default: {},
|
||||
format: 'Object',
|
||||
env: 'OAUTH_CLIENT_IDS',
|
||||
},
|
||||
disabledClients: {
|
||||
doc:
|
||||
'Comma-separated list of client ids for which service should be temporarily refused',
|
||||
env: 'OAUTH_CLIENTS_DISABLED',
|
||||
format: 'Array',
|
||||
default: [],
|
||||
},
|
||||
scopes: {
|
||||
doc: 'Some pre-defined list of scopes that will be inserted into the DB',
|
||||
env: 'OAUTH_SCOPES',
|
||||
format: 'Array',
|
||||
default: [],
|
||||
},
|
||||
clientAddressDepth: {
|
||||
doc: 'location of the client ip address in the remote address chain',
|
||||
env: 'CLIENT_ADDRESS_DEPTH',
|
||||
default: 3,
|
||||
},
|
||||
contentUrl: {
|
||||
doc: 'URL to UI page in fxa-content-server that starts OAuth flow',
|
||||
format: 'url',
|
||||
env: 'CONTENT_URL',
|
||||
default: 'https://accounts.firefox.com/oauth/',
|
||||
},
|
||||
db: {
|
||||
driver: {
|
||||
env: 'DB',
|
||||
format: ['mysql'],
|
||||
default: 'mysql',
|
||||
},
|
||||
autoUpdateClients: {
|
||||
doc: 'If true, update clients from config file settings',
|
||||
env: 'DB_AUTO_UPDATE_CLIENTS',
|
||||
format: 'Boolean',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
arg: 'node-env',
|
||||
doc: 'The current node.js environment',
|
||||
env: 'NODE_ENV',
|
||||
format: ['dev', 'test', 'stage', 'prod'],
|
||||
default: 'prod',
|
||||
},
|
||||
events: {
|
||||
enabled: {
|
||||
doc:
|
||||
'Whether or not config.events has to be properly set in production',
|
||||
default: true,
|
||||
format: 'Boolean',
|
||||
env: 'EVENTS_ENABLED',
|
||||
},
|
||||
region: {
|
||||
doc: 'AWS Region of fxa account events',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'FXA_EVENTS_REGION',
|
||||
},
|
||||
queueUrl: {
|
||||
doc: 'SQS queue url for fxa account events',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'FXA_EVENTS_QUEUE_URL',
|
||||
},
|
||||
},
|
||||
expiration: {
|
||||
accessToken: {
|
||||
doc: 'Access Tokens maximum expiration (can live shorter)',
|
||||
format: 'duration',
|
||||
default: '2 weeks',
|
||||
env: 'FXA_EXPIRATION_ACCESS_TOKEN',
|
||||
},
|
||||
accessTokenExpiryEpoch: {
|
||||
doc: 'Timestamp after which access token expiry is actively enforced',
|
||||
format: 'timestamp',
|
||||
default: '2017-01-01',
|
||||
env: 'FXA_EXPIRATION_ACCESS_TOKEN_EXPIRY_EPOCH',
|
||||
},
|
||||
code: {
|
||||
doc: 'Clients must trade codes for tokens before they expire',
|
||||
format: 'duration',
|
||||
default: '15 minutes',
|
||||
env: 'FXA_EXPIRATION_CODE',
|
||||
},
|
||||
},
|
||||
refreshToken: {
|
||||
updateAfter: {
|
||||
doc: 'lastUsedAt only gets updated after this delay',
|
||||
format: 'duration',
|
||||
default: '24 hours',
|
||||
env: 'FXA_REFRESH_TOKEN_UPDATE_AFTER',
|
||||
},
|
||||
},
|
||||
git: {
|
||||
commit: {
|
||||
doc: 'Commit SHA when in stage/production',
|
||||
format: 'String',
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
jwtAccessTokens: {
|
||||
enabled: {
|
||||
doc: 'Whether or not JWT access tokens are enabled',
|
||||
default: true,
|
||||
format: 'Boolean',
|
||||
env: 'JWT_ACCESS_TOKENS_ENABLED',
|
||||
},
|
||||
enabledClientIds: {
|
||||
doc: 'JWT access tokens are only returned for client_ids in this list',
|
||||
default: [],
|
||||
format: 'Array',
|
||||
env: 'JWT_ACCESS_TOKENS_ENABLED_CLIENT_IDS',
|
||||
},
|
||||
},
|
||||
localRedirects: {
|
||||
doc: 'When true, `localhost` and `127.0.0.1` always are legal redirects.',
|
||||
default: false,
|
||||
env: 'FXA_OAUTH_LOCAL_REDIRECTS',
|
||||
},
|
||||
mysql: {
|
||||
createSchema: { default: true, env: 'CREATE_MYSQL_SCHEMA' },
|
||||
user: { default: 'root', env: 'MYSQL_USERNAME' },
|
||||
password: { default: '', env: 'MYSQL_PASSWORD' },
|
||||
database: { default: 'fxa_oauth', env: 'MYSQL_DATABASE' },
|
||||
host: { default: '127.0.0.1', env: 'MYSQL_HOST' },
|
||||
port: { default: '3306', env: 'MYSQL_PORT' },
|
||||
connectionLimit: {
|
||||
doc: 'The maximum number of connections that the pool can use at once.',
|
||||
default: 10,
|
||||
env: 'MYSQL_CONNECTION_LIMIT',
|
||||
},
|
||||
timezone: {
|
||||
default: 'Z',
|
||||
doc:
|
||||
'The timezone configured on the MySQL server. This is used to type cast server date/time values to JavaScript `Date` object. Can be `local`, `Z`, or an offset in the form of or an offset in the form +HH:MM or -HH:MM.',
|
||||
env: 'MYSQL_TIMEZONE',
|
||||
format: 'String',
|
||||
},
|
||||
},
|
||||
openid: {
|
||||
keyFile: {
|
||||
doc: 'Path to private key JWK to sign various kinds of JWT tokens',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'FXA_OPENID_KEYFILE',
|
||||
},
|
||||
newKeyFile: {
|
||||
doc:
|
||||
'Path to private key JWK that will be used to sign JWTs in the future',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'FXA_OPENID_NEWKEYFILE',
|
||||
},
|
||||
oldKeyFile: {
|
||||
doc: 'Path to public key JWK that was used to sign JWTs in the past',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'FXA_OPENID_OLDKEYFILE',
|
||||
},
|
||||
key: {
|
||||
doc: 'Private key JWK to sign various kinds of JWT tokens',
|
||||
default: {},
|
||||
env: 'FXA_OPENID_KEY',
|
||||
},
|
||||
newKey: {
|
||||
doc: 'Private key JWK that will be used to sign JWTs in the future',
|
||||
default: {},
|
||||
env: 'FXA_OPENID_NEWKEY',
|
||||
},
|
||||
oldKey: {
|
||||
doc: 'Public key JWK that was used to sign JWTs in the past',
|
||||
default: {},
|
||||
env: 'FXA_OPENID_OLDKEY',
|
||||
},
|
||||
unsafelyAllowMissingActiveKey: {
|
||||
doc:
|
||||
'Do not error out if there is no active key; should only be used when initializing keys',
|
||||
default: false,
|
||||
format: 'Boolean',
|
||||
env: 'FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY',
|
||||
},
|
||||
issuer: {
|
||||
doc: 'The value of the `iss` property of the id_token',
|
||||
default: 'https://accounts.firefox.com',
|
||||
env: 'FXA_OPENID_ISSUER',
|
||||
},
|
||||
ttl: {
|
||||
doc: 'Number of milliseconds until id_token should expire',
|
||||
default: '5 minutes',
|
||||
format: 'duration',
|
||||
env: 'FXA_OPENID_TTL',
|
||||
},
|
||||
},
|
||||
ppid: {
|
||||
enabled: {
|
||||
doc: 'Whether pairwise pseudonymous identifiers (PPIDs) are enabled',
|
||||
default: false,
|
||||
format: 'Boolean',
|
||||
env: 'PPID_ENABLED',
|
||||
},
|
||||
enabledClientIds: {
|
||||
doc: 'client_ids that receive PPIDs',
|
||||
default: [],
|
||||
format: 'Array',
|
||||
env: 'PPID_CLIENT_IDS',
|
||||
},
|
||||
rotatingClientIds: {
|
||||
doc:
|
||||
'client_ids that receive automatically rotating PPIDs based on server time',
|
||||
default: [],
|
||||
format: 'Array',
|
||||
env: 'PPID_ROTATING_CLIENT_IDS',
|
||||
},
|
||||
rotationPeriodMS: {
|
||||
doc: 'salt used in HKDF for PPIDs, converted to milliseconds',
|
||||
format: 'duration',
|
||||
default: '6 hours',
|
||||
env: 'PPID_ROTATION_PERIOD',
|
||||
},
|
||||
salt: {
|
||||
doc: 'salt used in HKDF for PPIDs',
|
||||
default: 'YOU MUST CHANGE ME',
|
||||
format: 'String',
|
||||
env: 'PPID_SALT',
|
||||
},
|
||||
},
|
||||
publicUrl: {
|
||||
format: 'url',
|
||||
default: 'http://127.0.0.1:9000',
|
||||
env: 'PUBLIC_URL',
|
||||
},
|
||||
server: {
|
||||
host: { env: 'HOST', default: '127.0.0.1' },
|
||||
port: { env: 'PORT', format: 'port', default: 9000 },
|
||||
},
|
||||
serverInternal: {
|
||||
host: { env: 'HOST_INTERNAL', default: '127.0.0.1' },
|
||||
port: { env: 'PORT_INTERNAL', format: 'port', default: 9011 },
|
||||
},
|
||||
i18n: {
|
||||
defaultLanguage: {
|
||||
default: 'en',
|
||||
format: 'String',
|
||||
env: 'DEFAULT_LANG',
|
||||
},
|
||||
supportedLanguages: {
|
||||
default: [],
|
||||
format: 'Array',
|
||||
env: 'SUPPORTED_LANGS',
|
||||
},
|
||||
},
|
||||
unique: {
|
||||
clientSecret: {
|
||||
doc: 'Bytes of generated client_secrets',
|
||||
default: 32,
|
||||
},
|
||||
code: { doc: 'Bytes of generated codes', default: 32 },
|
||||
id: { doc: 'Bytes of generated DB ids', default: 8 },
|
||||
token: { doc: 'Bytes of generated tokens', default: 32 },
|
||||
developerId: { doc: 'Bytes of generated developer ids', default: 16 },
|
||||
},
|
||||
cacheControl: {
|
||||
doc:
|
||||
'Hapi: a string with the value of the "Cache-Control" header when caching is disabled',
|
||||
format: 'String',
|
||||
default: 'private, no-cache, no-store, must-revalidate',
|
||||
},
|
||||
sentryDsn: {
|
||||
doc: 'Sentry DSN for error and log reporting',
|
||||
default: '',
|
||||
format: 'String',
|
||||
env: 'SENTRY_DSN',
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
flow_id_key: {
|
||||
default: 'YOU MUST CHANGE ME',
|
||||
|
@ -1187,7 +1562,7 @@ let envConfig = path.join(__dirname, `${conf.get('env')}.json`);
|
|||
envConfig = `${envConfig},${process.env.CONFIG_FILES || ''}`;
|
||||
const files = envConfig.split(',').filter(fs.existsSync);
|
||||
conf.loadFile(files);
|
||||
conf.validate({ allowed: 'strict' });
|
||||
conf.validate();
|
||||
|
||||
// set the public url as the issuer domain for assertions
|
||||
conf.set('domain', url.parse(conf.get('publicUrl')).host);
|
||||
|
@ -1237,6 +1612,63 @@ if (conf.get('env') === 'dev') {
|
|||
}
|
||||
}
|
||||
|
||||
if (conf.get('oauthServer.openid.keyFile')) {
|
||||
const keyFile = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
conf.get('oauthServer.openid.keyFile')
|
||||
);
|
||||
conf.set('oauthServer.openid.keyFile', keyFile);
|
||||
// If the file doesnt exist, or contains an empty object, then there's no active key.
|
||||
conf.set('oauthServer.openid.key', null);
|
||||
if (fs.existsSync(keyFile)) {
|
||||
const key = JSON.parse(fs.readFileSync(keyFile));
|
||||
if (key && Object.keys(key).length > 0) {
|
||||
conf.set('oauthServer.openid.key', key);
|
||||
}
|
||||
}
|
||||
} else if (Object.keys(conf.get('oauthServer.openid.key')).length === 0) {
|
||||
conf.set('oauthServer.openid.key', null);
|
||||
}
|
||||
|
||||
if (conf.get('oauthServer.openid.newKeyFile')) {
|
||||
const newKeyFile = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
conf.get('oauthServer.openid.newKeyFile')
|
||||
);
|
||||
conf.set('oauthServer.openid.newKeyFile', newKeyFile);
|
||||
// If the file doesnt exist, or contains an empty object, then there's no new key.
|
||||
conf.set('oauthServer.openid.newKey', null);
|
||||
if (fs.existsSync(newKeyFile)) {
|
||||
const newKey = JSON.parse(fs.readFileSync(newKeyFile));
|
||||
if (newKey && Object.keys(newKey).length > 0) {
|
||||
conf.set('oauthServer.openid.newKey', newKey);
|
||||
}
|
||||
}
|
||||
} else if (Object.keys(conf.get('oauthServer.openid.newKey')).length === 0) {
|
||||
conf.set('oauthServer.openid.newKey', null);
|
||||
}
|
||||
|
||||
if (conf.get('oauthServer.openid.oldKeyFile')) {
|
||||
const oldKeyFile = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
conf.get('oauthServer.openid.oldKeyFile')
|
||||
);
|
||||
conf.set('oauthServer.openid.oldKeyFile', oldKeyFile);
|
||||
// If the file doesnt exist, or contains an empty object, then there's no old key.
|
||||
conf.set('oauthServer.openid.oldKey', null);
|
||||
if (fs.existsSync(oldKeyFile)) {
|
||||
const oldKey = JSON.parse(fs.readFileSync(oldKeyFile));
|
||||
if (oldKey && Object.keys(oldKey).length > 0) {
|
||||
conf.set('oauthServer.openid.oldKey', oldKey);
|
||||
}
|
||||
}
|
||||
} else if (Object.keys(conf.get('oauthServer.openid.oldKey')).length === 0) {
|
||||
conf.set('oauthServer.openid.oldKey', null);
|
||||
}
|
||||
|
||||
// Ensure secrets are not set to their default values in production.
|
||||
if (conf.get('isProduction')) {
|
||||
const SECRET_SETTINGS = [
|
||||
|
|
|
@ -20,23 +20,14 @@ const envConfig = path.join(
|
|||
`${conf.get('env')}.json`
|
||||
);
|
||||
|
||||
// This is sneaky and gross, but temporary.
|
||||
if (process.mainModule.filename.includes('key_server')) {
|
||||
if (fs.existsSync(envConfig)) {
|
||||
conf.loadFile(envConfig);
|
||||
}
|
||||
conf.set('mysql.createSchema', false);
|
||||
conf.validate();
|
||||
} else {
|
||||
const files = (envConfig + ',' + process.env.CONFIG_FILES)
|
||||
.split(',')
|
||||
.filter(fs.existsSync);
|
||||
conf.loadFile(files);
|
||||
conf.validate({
|
||||
allowed: 'strict',
|
||||
});
|
||||
conf.set('audience', conf.get('publicUrl'));
|
||||
}
|
||||
const files = (envConfig + ',' + process.env.CONFIG_FILES)
|
||||
.split(',')
|
||||
.filter(fs.existsSync);
|
||||
conf.loadFile(files);
|
||||
conf.validate({
|
||||
allowed: 'strict',
|
||||
});
|
||||
conf.set('audience', conf.get('publicUrl'));
|
||||
|
||||
if (conf.get('openid.keyFile')) {
|
||||
const keyFile = path.resolve(
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
const inherits = require('util').inherits;
|
||||
const messages = require('joi/lib/language').errors;
|
||||
const OauthError = require('./oauth/error');
|
||||
|
||||
const ERRNO = {
|
||||
SERVER_CONFIG_ERROR: 100,
|
||||
|
@ -200,6 +201,9 @@ AppError.translate = function(request, response) {
|
|||
if (response instanceof AppError) {
|
||||
return response;
|
||||
}
|
||||
if (OauthError.isOauthRoute(request && request.route.path)) {
|
||||
return OauthError.translate(response);
|
||||
}
|
||||
const payload = response.output.payload;
|
||||
const reason = response.reason;
|
||||
if (!payload) {
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
'use stict';
|
||||
|
||||
/* Utilities for verifing signed identity assertions.
|
||||
*
|
||||
* This service accepts two different kinds of identity assertions
|
||||
* for authenticating the caller:
|
||||
*
|
||||
* - A JWT, signed by one of a fixed set of trusted server-side secret
|
||||
* HMAC keys.
|
||||
* - A BrowserID assertion bundle, signed via BrowserID's public key
|
||||
* discovery mechanisms.
|
||||
*
|
||||
* The former is much simpler and easier to verify, so much so that
|
||||
* we do it inline in the server process. The later is much more
|
||||
* complicated and we need to call out to an external verifier process.
|
||||
* We hope to eventually phase out support for BrowserID assertions.
|
||||
*
|
||||
*/
|
||||
|
||||
const P = require('../promise');
|
||||
|
||||
const Joi = require('joi');
|
||||
const validators = require('./validators');
|
||||
|
||||
const AppError = require('./error');
|
||||
const config = require('../../config');
|
||||
const logger = require('./logging')('assertion');
|
||||
const { verifyJWT } = require('../../lib/serverJWT');
|
||||
|
||||
const HEX_STRING = /^[0-9a-f]+$/;
|
||||
|
||||
// FxA sends several custom claims, ref
|
||||
// https://github.com/mozilla/fxa/blob/master/packages/fxa-auth-server/docs/api.md#post-certificatesign
|
||||
const CLAIMS_SCHEMA = Joi.object({
|
||||
uid: Joi.string()
|
||||
.length(32)
|
||||
.regex(HEX_STRING)
|
||||
.required(),
|
||||
'fxa-generation': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.required(),
|
||||
'fxa-verifiedEmail': Joi.string()
|
||||
.max(255)
|
||||
.required(),
|
||||
'fxa-lastAuthAt': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.required(),
|
||||
iat: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.optional(),
|
||||
'fxa-tokenVerified': Joi.boolean().optional(),
|
||||
'fxa-sessionTokenId': validators.sessionTokenId.optional(),
|
||||
'fxa-amr': Joi.array()
|
||||
.items(Joi.string().alphanum())
|
||||
.optional(),
|
||||
'fxa-aal': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(3)
|
||||
.optional(),
|
||||
'fxa-profileChangedAt': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.optional(),
|
||||
'fxa-keysChangedAt': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.optional(),
|
||||
}).options({ stripUnknown: true });
|
||||
const validateClaims = P.promisify(CLAIMS_SCHEMA.validate, {
|
||||
context: CLAIMS_SCHEMA,
|
||||
});
|
||||
|
||||
const AUDIENCE = config.get('oauthServer.audience');
|
||||
const ALLOWED_ISSUER = config.get('oauthServer.browserid.issuer');
|
||||
|
||||
const request = P.promisify(
|
||||
require('request').defaults({
|
||||
url: config.get('oauthServer.browserid.verificationUrl'),
|
||||
pool: {
|
||||
maxSockets: config.get('oauthServer.browserid.maxSockets'),
|
||||
},
|
||||
}),
|
||||
{ multiArgs: true }
|
||||
);
|
||||
|
||||
function error(assertion, msg, val) {
|
||||
logger.info('invalidAssertion', { assertion, msg, val });
|
||||
throw AppError.invalidAssertion();
|
||||
}
|
||||
|
||||
// Verify a BrowserID assertion,
|
||||
// by posting to an external verifier service.
|
||||
|
||||
async function verifyBrowserID(assertion) {
|
||||
let res, body;
|
||||
try {
|
||||
[res, body] = await request({
|
||||
method: 'POST',
|
||||
json: {
|
||||
assertion: assertion,
|
||||
audience: AUDIENCE,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('verify.error', err);
|
||||
throw err;
|
||||
}
|
||||
if (!res || !body || body.status !== 'okay') {
|
||||
return error(assertion, 'non-okay response', body);
|
||||
}
|
||||
|
||||
const email = body.email;
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2 || parts[1] !== ALLOWED_ISSUER) {
|
||||
return error(assertion, 'invalid email', email);
|
||||
}
|
||||
if (body.issuer !== ALLOWED_ISSUER) {
|
||||
return error(assertion, 'invalid issuer', body.issuer);
|
||||
}
|
||||
const uid = parts[0];
|
||||
|
||||
const claims = body.idpClaims || {};
|
||||
claims.uid = uid;
|
||||
return claims;
|
||||
}
|
||||
|
||||
module.exports = async function verifyAssertion(assertion) {
|
||||
// We can differentiate between JWTs and BrowserID assertions
|
||||
// because the former cannot contain "~" while the later always do.
|
||||
let claims;
|
||||
if (/~/.test(assertion)) {
|
||||
claims = await verifyBrowserID(assertion);
|
||||
} else {
|
||||
try {
|
||||
claims = await verifyJWT(
|
||||
assertion,
|
||||
AUDIENCE,
|
||||
ALLOWED_ISSUER,
|
||||
config.get('oauthServer.authServerSecrets'),
|
||||
error
|
||||
);
|
||||
claims.uid = claims.sub;
|
||||
} catch (err) {
|
||||
return error(assertion, err.message);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await validateClaims(claims);
|
||||
} catch (err) {
|
||||
return error(assertion, err, claims);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
|
||||
|
||||
const AppError = require('./error');
|
||||
const logger = require('./logging')('server.auth_bearer');
|
||||
const token = require('./token');
|
||||
const validators = require('./validators');
|
||||
|
||||
const authName = 'authBearer';
|
||||
const authOAuthScope = ScopeSet.fromArray(['clients:write']);
|
||||
|
||||
exports.AUTH_STRATEGY = authName;
|
||||
exports.AUTH_SCHEME = authName;
|
||||
|
||||
exports.SCOPE_CLIENT_WRITE = authOAuthScope;
|
||||
|
||||
exports.strategy = function() {
|
||||
return {
|
||||
authenticate: async function authBearerStrategy(req, h) {
|
||||
var auth = req.headers.authorization;
|
||||
|
||||
logger.debug(authName + '.check', { header: auth });
|
||||
if (!auth || auth.indexOf('Bearer ') !== 0) {
|
||||
throw AppError.unauthorized('Bearer token not provided');
|
||||
}
|
||||
var tok = auth.split(' ')[1];
|
||||
|
||||
if (validators.accessToken.validate(tok).error) {
|
||||
throw AppError.unauthorized('Illegal Bearer token');
|
||||
}
|
||||
|
||||
return token.verify(tok).then(
|
||||
function tokenFound(details) {
|
||||
logger.info(authName + '.success', details);
|
||||
details.scope = details.scope.getScopeValues();
|
||||
return h.authenticated({
|
||||
credentials: details,
|
||||
});
|
||||
},
|
||||
function noToken(err) {
|
||||
logger.debug(authName + '.error', err);
|
||||
throw AppError.unauthorized('Bearer token invalid');
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
|
||||
|
||||
const AppError = require('./error');
|
||||
const logger = require('./logging')('server.auth');
|
||||
const token = require('./token');
|
||||
const validators = require('./validators');
|
||||
|
||||
const WHITELIST = require('../../config')
|
||||
.get('oauthServer.admin.whitelist')
|
||||
.map(function(re) {
|
||||
return new RegExp(re);
|
||||
});
|
||||
|
||||
exports.AUTH_STRATEGY = 'dogfood';
|
||||
exports.AUTH_SCHEME = 'bearer';
|
||||
|
||||
exports.SCOPE_CLIENT_MANAGEMENT = ScopeSet.fromArray(['oauth']);
|
||||
|
||||
exports.strategy = function() {
|
||||
logger.verbose('auth_client.whitelist', WHITELIST);
|
||||
|
||||
return {
|
||||
authenticate: async function dogfoodStrategy(req, h) {
|
||||
var auth = req.headers.authorization;
|
||||
logger.debug('check.auth', { header: auth });
|
||||
if (!auth || auth.indexOf('Bearer ') !== 0) {
|
||||
throw AppError.unauthorized('Bearer token not provided');
|
||||
}
|
||||
var tok = auth.split(' ')[1];
|
||||
|
||||
if (!validators.HEX_STRING.test(tok)) {
|
||||
throw AppError.unauthorized('Illegal Bearer token');
|
||||
}
|
||||
|
||||
return token.verify(tok).then(
|
||||
function tokenFound(details) {
|
||||
if (details.scope.contains(exports.SCOPE_CLIENT_MANAGEMENT)) {
|
||||
logger.debug('check.whitelist');
|
||||
var blocked = !WHITELIST.some(function(re) {
|
||||
return re.test(details.email);
|
||||
});
|
||||
if (blocked) {
|
||||
logger.warn('whitelist.blocked', {
|
||||
email: details.email,
|
||||
token: tok,
|
||||
});
|
||||
throw AppError.forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('success', details);
|
||||
details.scope = details.scope.getScopeValues();
|
||||
return h.authenticated({ credentials: details });
|
||||
},
|
||||
function noToken(err) {
|
||||
logger.debug('error', err);
|
||||
throw AppError.unauthorized('Bearer token invalid');
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,150 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const crypto = require('crypto');
|
||||
const buf = require('buf').hex;
|
||||
const Joi = require('joi');
|
||||
|
||||
const AppError = require('./error');
|
||||
const validators = require('./validators');
|
||||
const db = require('./db');
|
||||
const encrypt = require('./encrypt');
|
||||
const logger = require('./logging')('client');
|
||||
|
||||
// Client credentials can be provided in either the Authorization header
|
||||
// or the request body, but not both.
|
||||
// These are some re-useable validators to assert that requirement.
|
||||
module.exports.clientAuthValidators = {
|
||||
headers: Joi.object({
|
||||
authorization: Joi.string()
|
||||
.regex(validators.BASIC_AUTH_HEADER)
|
||||
.optional(),
|
||||
}).options({ allowUnknown: true, stripUnknown: false }),
|
||||
|
||||
// The use of `$headers` here is Joi syntax for a "context reference"
|
||||
// as described at https://hapi.dev/family/joi/?v=16.1.4#refkey-options.
|
||||
// Hapi provides the headers as part of the context when validating request payload,
|
||||
// as noted in https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseschema.
|
||||
clientId: validators.clientId.when('$headers.authorization', {
|
||||
is: Joi.string().required(),
|
||||
then: Joi.forbidden(),
|
||||
}),
|
||||
|
||||
clientSecret: validators.clientSecret.when('$headers.authorization', {
|
||||
is: Joi.string().required(),
|
||||
then: Joi.forbidden(),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract and normalize client credentials from a request.
|
||||
* Clients may provide credentials in either the Authorization header
|
||||
* or the request body, but not both.
|
||||
*
|
||||
* @param {Object} headers the headers from the request
|
||||
* @param {Object} params the payload from the request
|
||||
* @returns {Object} credentials
|
||||
* @param {String} client_id
|
||||
* @param {String} [client_secret]
|
||||
*/
|
||||
module.exports.getClientCredentials = function getClientCredentials(
|
||||
headers,
|
||||
params
|
||||
) {
|
||||
const creds = {};
|
||||
creds.client_id = params.client_id;
|
||||
if (params.client_secret) {
|
||||
creds.client_secret = params.client_secret;
|
||||
}
|
||||
|
||||
// Clients are allowed to provide credentials in either
|
||||
// the Authorization header or request body, but not both.
|
||||
if (headers.authorization) {
|
||||
const authzMatch = validators.BASIC_AUTH_HEADER.exec(headers.authorization);
|
||||
const err = new AppError.invalidRequestParameter({
|
||||
keys: ['authorization'],
|
||||
});
|
||||
if (!authzMatch || creds.client_id || creds.client_secret) {
|
||||
throw err;
|
||||
}
|
||||
const [clientId, clientSecret, ...rest] = Buffer.from(
|
||||
authzMatch[1],
|
||||
'base64'
|
||||
)
|
||||
.toString()
|
||||
.split(':');
|
||||
if (rest.length !== 0) {
|
||||
throw err;
|
||||
}
|
||||
creds.client_id = Joi.attempt(clientId, validators.clientId, err);
|
||||
creds.client_secret = Joi.attempt(
|
||||
clientSecret,
|
||||
validators.clientSecret,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return creds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate a request made by an OAuth client, using credentials from
|
||||
* either the Authorization header or request body parameters.
|
||||
*
|
||||
* @param {Object} headers the headers from the request
|
||||
* @param {Object} params the payload from the request
|
||||
* @returns {Promise} resolves with info about the client, or
|
||||
* rejects if invalid credentials were provided
|
||||
*/
|
||||
module.exports.authenticateClient = async function authenticateClient(
|
||||
headers,
|
||||
params
|
||||
) {
|
||||
const creds = exports.getClientCredentials(headers, params);
|
||||
|
||||
const client = await getClientById(creds.client_id);
|
||||
|
||||
// Public clients can't be authenticated in any useful way,
|
||||
// and should never submit a client_secret.
|
||||
if (client.publicClient) {
|
||||
if (creds.client_secret) {
|
||||
throw new AppError.invalidRequestParameter({ keys: ['client_secret'] });
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// Check client_secret against both current and previous stored secrets,
|
||||
// to allow for seamless rotation of the secret.
|
||||
if (!creds.client_secret) {
|
||||
throw new AppError.invalidRequestParameter({ keys: ['client_secret'] });
|
||||
}
|
||||
const submitted = encrypt.hash(buf(creds.client_secret));
|
||||
const stored = client.hashedSecret;
|
||||
if (crypto.timingSafeEqual(submitted, stored)) {
|
||||
return client;
|
||||
}
|
||||
const storedPrevious = client.hashedSecretPrevious;
|
||||
if (storedPrevious) {
|
||||
if (crypto.timingSafeEqual(submitted, storedPrevious)) {
|
||||
logger.info('client.matchSecretPrevious', { client: client.id });
|
||||
return client;
|
||||
}
|
||||
}
|
||||
logger.info('client.mismatchSecret', { client: client.id });
|
||||
logger.verbose('client.mismatchSecret.details', {
|
||||
submitted: submitted,
|
||||
db: stored,
|
||||
dbPrevious: storedPrevious,
|
||||
});
|
||||
throw AppError.incorrectSecret(client.id);
|
||||
};
|
||||
|
||||
async function getClientById(clientId) {
|
||||
const client = await db.getClient(buf(clientId));
|
||||
if (!client) {
|
||||
logger.debug('client.notFound', { id: clientId });
|
||||
throw AppError.unknownClient(clientId);
|
||||
}
|
||||
return client;
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/***
|
||||
* This module consists of helpers that are used by the MySQL database engine.
|
||||
**/
|
||||
|
||||
const unbuf = require('buf').unbuf.hex;
|
||||
const ScopeSet = require('../../../../fxa-shared').oauth.scopes;
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* This helper takes a list of active oauth tokens and produces an aggregate
|
||||
* summary of the active clients, by:
|
||||
*
|
||||
* * merging all scopes into a single set
|
||||
* * taking the max token creation time a the last access time
|
||||
*
|
||||
* The resulting array is in sorted order by last access time, then client name.
|
||||
*
|
||||
* @param {Array} activeClientTokens
|
||||
* An array of OAuth tokens, annotated with client name:
|
||||
* (OAuth client) name|(OAuth client) id|(Token) createdAt|(Token) scope
|
||||
* @returns {Array}
|
||||
*/
|
||||
aggregateActiveClients: function aggregateActiveClients(activeClientTokens) {
|
||||
if (!activeClientTokens) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create an Object that stores unique OAuth client data
|
||||
var activeClients = {};
|
||||
activeClientTokens.forEach(function(clientTokenObj) {
|
||||
var clientIdHex = unbuf(clientTokenObj.id);
|
||||
|
||||
if (!activeClients[clientIdHex]) {
|
||||
// add the OAuth client if not already in the Object
|
||||
activeClients[clientIdHex] = {
|
||||
id: clientTokenObj.id,
|
||||
name: clientTokenObj.name,
|
||||
lastAccessTime: clientTokenObj.createdAt,
|
||||
scope: ScopeSet.fromArray([]),
|
||||
};
|
||||
}
|
||||
|
||||
activeClients[clientIdHex].scope.add(clientTokenObj.scope);
|
||||
|
||||
var clientTokenTime =
|
||||
clientTokenObj.lastUsedAt || clientTokenObj.createdAt;
|
||||
if (clientTokenTime > activeClients[clientIdHex].lastAccessTime) {
|
||||
// only update the createdAt if it is newer
|
||||
activeClients[clientIdHex].lastAccessTime = clientTokenTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort the scopes alphabetically, convert the Object structure to an array
|
||||
var activeClientsArray = Object.keys(activeClients).map(function(key) {
|
||||
activeClients[key].scope = activeClients[key].scope
|
||||
.getScopeValues()
|
||||
.sort();
|
||||
return activeClients[key];
|
||||
});
|
||||
|
||||
// Sort the final Array structure first by lastAccessTime and then name
|
||||
activeClientsArray.sort(function(a, b) {
|
||||
if (b.lastAccessTime > a.lastAccessTime) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.lastAccessTime < a.lastAccessTime) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return activeClientsArray;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,205 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const unbuf = require('buf').unbuf.hex;
|
||||
|
||||
const P = require('../../promise');
|
||||
|
||||
const config = require('../../../config');
|
||||
const encrypt = require('../encrypt');
|
||||
const logger = require('../logging')('db');
|
||||
const mysql = require('./mysql');
|
||||
|
||||
function clientEquals(configClient, dbClient) {
|
||||
var props = Object.keys(configClient);
|
||||
for (var i = 0; i < props.length; i++) {
|
||||
var prop = props[i];
|
||||
var configProp = unbuf(configClient[prop]);
|
||||
var dbProp = unbuf(dbClient[prop]);
|
||||
if (configProp !== dbProp) {
|
||||
logger.debug('clients.differ', {
|
||||
prop: prop,
|
||||
configProp: configProp,
|
||||
dbProp: dbProp,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function convertClientToConfigFormat(client) {
|
||||
var out = {};
|
||||
|
||||
for (var key in client) {
|
||||
if (key === 'hashedSecret' || key === 'hashedSecretPrevious') {
|
||||
out[key] = unbuf(client[key]);
|
||||
} else if (key === 'trusted' || key === 'canGrant') {
|
||||
out[key] = !!client[key]; // db stores booleans as 0 or 1.
|
||||
} else if (typeof client[key] !== 'function') {
|
||||
out[key] = unbuf(client[key]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function preClients() {
|
||||
var clients = config.get('oauthServer.clients');
|
||||
if (clients && clients.length) {
|
||||
logger.debug('predefined.loading', { clients: clients });
|
||||
return P.all(
|
||||
clients.map(function(c) {
|
||||
if (c.secret) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'Do not keep client secrets in the config file.' + // eslint-disable-line no-console
|
||||
' Use the `hashedSecret` field instead.\n\n' +
|
||||
'\tclient=%s has `secret` field\n' +
|
||||
'\tuse hashedSecret="%s" instead',
|
||||
c.id,
|
||||
unbuf(encrypt.hash(c.secret))
|
||||
);
|
||||
return P.reject(
|
||||
new Error('Do not keep client secrets in the config file.')
|
||||
);
|
||||
}
|
||||
|
||||
// ensure the required keys are present.
|
||||
var err = null;
|
||||
var REQUIRED_CLIENTS_KEYS = [
|
||||
'id',
|
||||
'hashedSecret',
|
||||
'name',
|
||||
'imageUri',
|
||||
'redirectUri',
|
||||
'trusted',
|
||||
'canGrant',
|
||||
];
|
||||
REQUIRED_CLIENTS_KEYS.forEach(function(key) {
|
||||
if (!(key in c)) {
|
||||
var data = { key: key, name: c.name || 'unknown' };
|
||||
logger.error('client.missing.keys', data);
|
||||
err = new Error('Client config has missing keys');
|
||||
}
|
||||
});
|
||||
if (err) {
|
||||
return P.reject(err);
|
||||
}
|
||||
|
||||
// ensure booleans are boolean and not undefined
|
||||
c.trusted = !!c.trusted;
|
||||
c.canGrant = !!c.canGrant;
|
||||
c.publicClient = !!c.publicClient;
|
||||
|
||||
// Modification of the database at startup in production and stage is
|
||||
// not preferred. This option will be set to false on those stacks.
|
||||
if (!config.get('oauthServer.db.autoUpdateClients')) {
|
||||
return P.resolve();
|
||||
}
|
||||
|
||||
return exports.getClient(c.id).then(function(client) {
|
||||
if (client) {
|
||||
client = convertClientToConfigFormat(client);
|
||||
logger.info('client.compare', { id: c.id });
|
||||
if (clientEquals(client, c)) {
|
||||
logger.info('client.compare.equal', { id: c.id });
|
||||
} else {
|
||||
logger.warn('client.compare.differs', {
|
||||
id: c.id,
|
||||
before: client,
|
||||
after: c,
|
||||
});
|
||||
return exports.updateClient(c);
|
||||
}
|
||||
} else {
|
||||
return exports.registerClient(c);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return P.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert pre-defined list of scopes into the DB
|
||||
*/
|
||||
function scopes() {
|
||||
var scopes = config.get('oauthServer.scopes');
|
||||
if (scopes && scopes.length) {
|
||||
logger.debug('scopes.loading', JSON.stringify(scopes));
|
||||
|
||||
return P.all(
|
||||
scopes.map(function(s) {
|
||||
return exports.getScope(s.scope).then(function(existing) {
|
||||
if (existing) {
|
||||
logger.verbose('scopes.existing', s);
|
||||
return;
|
||||
}
|
||||
|
||||
return exports.registerScope(s);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var driver;
|
||||
function withDriver() {
|
||||
if (driver) {
|
||||
return P.resolve(driver);
|
||||
}
|
||||
const p = mysql.connect(config.get('oauthServer.mysql'));
|
||||
|
||||
return p
|
||||
.then(function(store) {
|
||||
logger.debug('connected', {
|
||||
driver: 'mysql',
|
||||
});
|
||||
driver = store;
|
||||
})
|
||||
.then(exports._initialClients)
|
||||
.then(function() {
|
||||
return driver;
|
||||
});
|
||||
}
|
||||
|
||||
const proxyReturn = logger.isEnabledFor(logger.VERBOSE)
|
||||
? function verboseReturn(promise, method) {
|
||||
return promise.then(function(ret) {
|
||||
logger.verbose('proxied', { method: method, ret: ret });
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
: function identity(x) {
|
||||
return x;
|
||||
};
|
||||
|
||||
function proxy(method) {
|
||||
return function proxied() {
|
||||
var args = arguments;
|
||||
return withDriver()
|
||||
.then(function(driver) {
|
||||
logger.verbose('proxying', { method: method, args: args });
|
||||
return proxyReturn(driver[method].apply(driver, args), method);
|
||||
})
|
||||
.catch(function(err) {
|
||||
logger.error(method, err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Object.keys(mysql.prototype).forEach(function(key) {
|
||||
exports[key] = proxy(key);
|
||||
});
|
||||
|
||||
exports.disconnect = function disconnect() {
|
||||
driver = null;
|
||||
};
|
||||
|
||||
exports._initialClients = function() {
|
||||
return preClients().then(scopes);
|
||||
};
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,9 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// The expected patch level of the database.
|
||||
// Update this if you add a new patch, and don't forget to update
|
||||
// the documentation for the current schema in ../schema.sql.
|
||||
|
||||
module.exports.level = 28;
|
|
@ -0,0 +1,9 @@
|
|||
-- Create the 'dbMetadata' table.
|
||||
-- Note: This should be the only thing in this initial patch.
|
||||
|
||||
CREATE TABLE dbMetadata (
|
||||
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO dbMetadata SET name = 'schema-patch-level', value = '1';
|
|
@ -0,0 +1,2 @@
|
|||
-- -- drop the dbMetadata table
|
||||
-- DROP TABLE dbMetadata;
|
|
@ -0,0 +1,45 @@
|
|||
-- Create the initial set of tables.
|
||||
--
|
||||
-- Since this is the first migration, we use `IF NOT EXISTS` to allow us
|
||||
-- to run this on a db that already has the original schema in place. The
|
||||
-- patch will then be a no-op. Subsequent patches should *not* use `IF
|
||||
-- NOT EXISTS` but should fail noisily if the db is in an unexpected state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id BINARY(8) PRIMARY KEY,
|
||||
secret BINARY(32) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
imageUri VARCHAR(256) NOT NULL,
|
||||
redirectUri VARCHAR(256) NOT NULL,
|
||||
whitelisted BOOLEAN DEFAULT FALSE,
|
||||
canGrant BOOLEAN DEFAULT FALSE,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS codes (
|
||||
code BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX codes_client_id(clientId),
|
||||
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX codes_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX tokens_client_id(clientId),
|
||||
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX tokens_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
type VARCHAR(16) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
UPDATE dbMetadata SET value = '2' WHERE name = 'schema-patch-level';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- Drop all the tables.
|
||||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- DROP TABLE clients;
|
||||
-- DROP TABLE codes;
|
||||
-- DROP TABLE tokens;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '1' WHERE name = 'schema-patch-level';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Add `authAt` column to the `codes` table.
|
||||
|
||||
ALTER TABLE codes ADD COLUMN authAt BIGINT DEFAULT 0;
|
||||
|
||||
UPDATE dbMetadata SET value = '3' WHERE name = 'schema-patch-level';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Remove `authAt` column from the `codes` table.
|
||||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE codes DROP COLUMN authAt;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '2' WHERE name = 'schema-patch-level';
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- Adds support for Client Developers for OAuth clients
|
||||
|
||||
CREATE TABLE developers (
|
||||
developerId BINARY(16) NOT NULL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(email)
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE clientDevelopers (
|
||||
rowId BINARY(8) NOT NULL PRIMARY KEY,
|
||||
developerId BINARY(16) NOT NULL,
|
||||
FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
UPDATE dbMetadata SET value = '4' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Remove support for Client Developers for OAuth clients
|
||||
|
||||
-- DROP TABLE clientDevelopers;
|
||||
-- DROP TABLE developers;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '3' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,13 @@
|
|||
-- Begins process of renaming "whitelisted" to "trusted".
|
||||
-- We need to drop the "whitelisted" column in a separate patch in order
|
||||
-- to safely deploy this without downtime.
|
||||
|
||||
ALTER TABLE clients ADD COLUMN trusted BOOLEAN DEFAULT FALSE;
|
||||
UPDATE clients SET trusted=whitelisted;
|
||||
|
||||
-- Adds new "termsUri" and "privacyUri" columns for third-party clients.
|
||||
|
||||
ALTER TABLE clients ADD COLUMN termsUri VARCHAR(256) NOT NULL AFTER redirectUri;
|
||||
ALTER TABLE clients ADD COLUMN privacyUri VARCHAR(256) NOT NULL AFTER termsUri;
|
||||
|
||||
UPDATE dbMetadata SET value = '5' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,9 @@
|
|||
-- Remove "termsUri" and "privacyUri" columns".
|
||||
-- Remove "trusted" column, ensuring to sync with old "whitelist" column.
|
||||
|
||||
-- ALTER TABLE clients DROP COLUMN privacyUri;
|
||||
-- ALTER TABLE clients DROP COLUMN termsUri;
|
||||
-- UPDATE clients SET whitelisted=trusted;
|
||||
-- ALTER TABLE clients DROP COLUMN trusted;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '4' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,24 @@
|
|||
-- Add refreshTokens
|
||||
|
||||
CREATE TABLE refreshTokens (
|
||||
token BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX tokens_client_id(clientId),
|
||||
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX tokens_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
-- Add expiresAt column for access tokens
|
||||
|
||||
ALTER TABLE tokens ADD COLUMN expiresAt TIMESTAMP NOT NULL DEFAULT "1980-01-01 00:00:00";
|
||||
UPDATE tokens SET expiresAt = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 2 WEEK);
|
||||
|
||||
-- Add offline column to codes
|
||||
|
||||
ALTER TABLE codes ADD COLUMN offline BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE dbMetadata SET value = '6' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,8 @@
|
|||
-- Remove refreshToken table and expiresAt column from tokens table.
|
||||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- DROP TABLE refreshTokens;
|
||||
-- ALTER TABLE tokens DROP COLUMN expiresAt;
|
||||
-- ALTER TABLE codes DROP COLUMN offline;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '5' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,7 @@
|
|||
-- Change clients.secret to clients.hashedSecret
|
||||
-- Drop whitelisted column
|
||||
|
||||
ALTER TABLE clients CHANGE secret hashedSecret BINARY(32);
|
||||
ALTER TABLE clients DROP COLUMN whitelisted;
|
||||
|
||||
UPDATE dbMetadata SET value = '7' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,8 @@
|
|||
-- Change clients.hashedSecret to clients.secret
|
||||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients CHANGE hashedSecret secret BINARY(32);
|
||||
-- ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
|
||||
-- UPDATE clients SET whitelisted=trusted;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '6' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE clients CHANGE hashedSecret secret BINARY(32);
|
||||
ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
|
||||
UPDATE clients SET whitelisted=trusted;
|
||||
|
||||
UPDATE dbMetadata SET value = '8' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,8 @@
|
|||
-- Change clients.secret to clients.hashedSecret
|
||||
-- Drop whitelisted column
|
||||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients CHANGE secret hashedSecret BINARY(32);
|
||||
-- ALTER TABLE clients DROP COLUMN whitelisted;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '7' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Add hashedSecret column, to replace secret column.
|
||||
|
||||
ALTER TABLE clients ADD COLUMN hashedSecret BINARY(32);
|
||||
UPDATE clients SET hashedSecret = secret;
|
||||
|
||||
UPDATE dbMetadata SET value = '9' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients DROP COLUMN hashedSecret;
|
||||
-- UPDATE dbMetadata SET value = '8' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- Remove `secret` column.
|
||||
|
||||
ALTER TABLE clients DROP COLUMN secret;
|
||||
|
||||
UPDATE dbMetadata SET value = '10' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients ADD COLUMN secret BINARY(32);
|
||||
-- UPDATE clients SET secret = hashedSecret;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '9' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- dropping NOT NULL constraint
|
||||
|
||||
ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256);
|
||||
|
||||
UPDATE dbMetadata SET value = '11' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256) NOT NULL;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '10' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- Drop whitelisted column
|
||||
|
||||
ALTER TABLE clients DROP COLUMN whitelisted;
|
||||
|
||||
UPDATE dbMetadata SET value = '12' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients ADD COLUMN whitelisted BOOLEAN DEFAULT FALSE;
|
||||
-- UPDATE clients SET whitelisted=trusted;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '11' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,7 @@
|
|||
-- Re-create NOT NULL constraint on the email column.
|
||||
-- We weren't able to drop it in production, this migration
|
||||
-- brings it back in our dev environments.
|
||||
|
||||
ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256) NOT NULL;
|
||||
|
||||
UPDATE dbMetadata SET value = '13' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256);
|
||||
|
||||
-- UPDATE dbMetadata SET value = '12' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- Add hashedSecretPrevious column
|
||||
|
||||
ALTER TABLE clients ADD COLUMN hashedSecretPrevious BINARY(32);
|
||||
|
||||
UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE clients DROP COLUMN hashedSecretPrevious;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '13' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- Add index idx_expiresAt to token table
|
||||
|
||||
ALTER TABLE tokens ADD INDEX idx_expiresAt (expiresAt), ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,5 @@
|
|||
-- (commented out to avoid accidentally running this in production...)
|
||||
|
||||
-- ALTER TABLE tokens DROP INDEX idx_expiresAt;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Remove "termsUri" and "privacyUri" columns".
|
||||
|
||||
ALTER TABLE clients DROP COLUMN privacyUri;
|
||||
ALTER TABLE clients DROP COLUMN termsUri;
|
||||
|
||||
UPDATE dbMetadata SET value = '16' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Adds new "termsUri" and "privacyUri" columns for third-party clients.
|
||||
|
||||
--ALTER TABLE clients ADD COLUMN termsUri VARCHAR(256) NOT NULL AFTER redirectUri;
|
||||
--ALTER TABLE clients ADD COLUMN privacyUri VARCHAR(256) NOT NULL AFTER termsUri;
|
||||
|
||||
--UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Add `lastUsedAt` column to the `refreshTokens` table.
|
||||
|
||||
ALTER TABLE refreshTokens ADD COLUMN lastUsedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '17' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- Drop `lastUsedAt` column to the `refreshTokens` table.
|
||||
|
||||
-- ALTER TABLE refreshTokens DROP COLUMN lastUsedAt,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '16' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,11 @@
|
|||
-- Add `publicClient` column to the `clients` table.
|
||||
ALTER TABLE clients ADD COLUMN publicClient BOOLEAN DEFAULT FALSE NOT NULL AFTER canGrant;
|
||||
UPDATE clients SET publicClient=false;
|
||||
|
||||
-- Add `codeChallengeMethod` and `codeChallenge` column to the `codes` table.
|
||||
ALTER TABLE codes
|
||||
ADD COLUMN codeChallengeMethod VARCHAR(256) AFTER offline,
|
||||
ADD COLUMN codeChallenge VARCHAR(256) AFTER codeChallengeMethod,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,14 @@
|
|||
-- Drop `publicClient` column from the `clients` table.
|
||||
|
||||
-- ALTER TABLE clients DROP COLUMN publicClient,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- Drop `codeChallengeMethod` and `codeChallenge` column from the `codes` table.
|
||||
|
||||
-- ALTER TABLE codes DROP COLUMN codeChallengeMethod,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes DROP COLUMN codeChallenge,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '17' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE codes ADD COLUMN keysJwe MEDIUMTEXT,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '19' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
-- ALTER TABLE codes DROP COLUMN keysJwe,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,10 @@
|
|||
ALTER TABLE clients
|
||||
ADD COLUMN allowedScopes VARCHAR(1024) AFTER trusted,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
CREATE TABLE scopes (
|
||||
scope VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
hasScopedKeys BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
UPDATE dbMetadata SET value = '20' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- ALTER TABLE clients DROP COLUMN allowedScopes,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- DROP TABLE scopes;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '19' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,14 @@
|
|||
-- Add columns to stash 'amr' and 'aal' on the codes table.
|
||||
-- The 'amr' column will be a comma-separated string.
|
||||
-- It's tempting to use MySQL's SET datatype to save on storage space here:
|
||||
--
|
||||
-- https://dev.mysql.com/doc/refman/5.7/en/set.html
|
||||
--
|
||||
-- But codes are transient, so it doesn't seem worthwhile to
|
||||
-- trade the maintenance complexity for a little storage space.
|
||||
ALTER TABLE codes
|
||||
ADD COLUMN amr VARCHAR(128) AFTER authAt,
|
||||
ADD COLUMN aal TINYINT AFTER amr,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '21' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,6 @@
|
|||
-- ALTER TABLE codes
|
||||
-- DROP COLUMN amr,
|
||||
-- DROP COLUMN aal,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '20' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,11 @@
|
|||
-- Add column to stash the `profileChangedAt` value
|
||||
ALTER TABLE codes ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE refreshTokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '22' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,13 @@
|
|||
-- ALTER TABLE tokens
|
||||
-- DROP COLUMN profileChangedAt,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes
|
||||
-- DROP COLUMN profileChangedAt,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE refreshTokens
|
||||
-- DROP COLUMN profileChangedAt,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '21`' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,55 @@
|
|||
-- Drop foreign key constraints. They make DB migrations harder
|
||||
-- and aren't really providing us much value in practice.
|
||||
|
||||
-- The `clientDevelopers` table needs indexes on `developerId` a `clientId`
|
||||
-- for fast lookup. Prior to this patch, we were taking advantage of the
|
||||
-- index that is automatically created to enforce foreign key constraints,
|
||||
-- which the MySQL docs at [1] describe as:
|
||||
--
|
||||
-- """
|
||||
-- In the referencing table, there must be an index where the foreign key
|
||||
-- columns are listed as the first columns in the same order. Such an index
|
||||
-- is created on the referencing table automatically if it does not exist.
|
||||
-- This index might be silently dropped later, if you create another index
|
||||
-- that can be used to enforce the foreign key constraint.
|
||||
-- """
|
||||
-- [1] https://dev.mysql.com/doc/refman/5.7/en/create-table-foreign-keys.html
|
||||
--
|
||||
-- The "might" in there leaves some doubt about the exact circumstances under
|
||||
-- which we can depend on this index continuing to exist, so this migration
|
||||
-- explicitly creates the indexes we need. It's a two step process:
|
||||
--
|
||||
-- 1) Explicitly create the indexes we need. This "might" cause the ones
|
||||
-- that were created automatically for the FK constraint to be dropped.
|
||||
--
|
||||
-- 2) Drop the FK constraints, which might leave behind the auto-created
|
||||
-- indexes if they weren't dropped in (1) above.
|
||||
--
|
||||
-- In my testing, the auto-created indexes are indeed dropped in favour
|
||||
-- of the explicit ones. If they aren't, then at least we wind up with
|
||||
-- duplicate indexes which can be cleaned up manually, which is much better
|
||||
-- than winding up with no indexes at all.
|
||||
--
|
||||
|
||||
ALTER TABLE clientDevelopers ADD INDEX idx_clientDevelopers_developerId(developerId),
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE clientDevelopers ADD INDEX idx_clientDevelopers_clientId(clientId),
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE clientDevelopers DROP FOREIGN KEY clientDevelopers_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE clientDevelopers DROP FOREIGN KEY clientDevelopers_ibfk_2,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '23' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
-- ALTER TABLE clientDevelopers DROP INDEX idx_clientDevelopers_developerId,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE clientDevelopers DROP INDEX idx_clientDevelopers_clientId(clientId),
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE clientDevelopers ADD FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE clientDevelopers ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '22' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,8 @@
|
|||
-- This removes the `profileChangedAt` column on the tokens table.
|
||||
-- Since the tokens table is so large, this migration causes some issues.
|
||||
-- When the tokens get purged and the table is a bit smaller we can attempt to
|
||||
-- add this column.
|
||||
ALTER TABLE tokens DROP COLUMN profileChangedAt,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '24' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
-- ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '23`' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,26 @@
|
|||
-- This restores some of the FOREIGN KEY constraints that were
|
||||
-- dropped in migration #23. They unexpectedly caused MySQL to
|
||||
-- change its query plan and start scanning large chunks of the
|
||||
-- `tokens` table. More context here:
|
||||
--
|
||||
-- https://github.com/mozilla/fxa-auth-server/issues/2695
|
||||
--
|
||||
-- We'll remove them again in a follow-up migration, and adjust
|
||||
-- the queries to ensure they use the right indexes. For now
|
||||
-- we're just putting them back so that the alleged state of
|
||||
-- the db matches what's in production.
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
|
||||
UPDATE dbMetadata SET value = '25' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
-- ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '24' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,18 @@
|
|||
-- Drop foreign key constraints. They make DB migrations harder
|
||||
-- and aren't really providing us much value in practice.
|
||||
--
|
||||
-- We previously attempted this in migration #23, but had to roll
|
||||
-- it back because it caused MySQL's query planner to make some poor
|
||||
-- choices. We've re-worked the queries to help it make better
|
||||
-- choices and are now ready to try again.
|
||||
|
||||
ALTER TABLE refreshTokens DROP FOREIGN KEY refreshTokens_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE codes DROP FOREIGN KEY codes_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
ALTER TABLE tokens DROP FOREIGN KEY tokens_ibfk_1,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '26' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
-- ALTER TABLE refreshTokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE tokens ADD FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '25' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,7 @@
|
|||
-- The `profileChangedAt` column was removed in patch-023-024 because
|
||||
-- it caused migration issues. Now that we have resolved our migration
|
||||
-- issues, it is safe to add this column back.
|
||||
ALTER TABLE tokens ADD COLUMN profileChangedAt BIGINT DEFAULT NULL,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '27' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
-- ALTER TABLE tokens DROP COLUMN profileChangedAt,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '26' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE codes ADD COLUMN sessionTokenId BINARY(32) DEFAULT NULL,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '28' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,4 @@
|
|||
-- ALTER TABLE codes DROP COLUMN sessionTokenId,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '27' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,84 @@
|
|||
--
|
||||
-- This file represents the current db schema.
|
||||
-- It exists mainly for documentation purposes; any automated database
|
||||
-- modifications are controlled by the files in the ./patches/ directory.
|
||||
--
|
||||
-- If you make a change here, you should also create a new database patch
|
||||
-- file and increment the level in ./patch.js to reflect the change.
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id BINARY(8) PRIMARY KEY,
|
||||
hashedSecret BINARY(32),
|
||||
hashedSecretPrevious BINARY(32),
|
||||
name VARCHAR(256) NOT NULL,
|
||||
imageUri VARCHAR(256) NOT NULL,
|
||||
redirectUri VARCHAR(256) NOT NULL,
|
||||
canGrant BOOLEAN DEFAULT FALSE,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
trusted BOOLEAN DEFAULT FALSE,
|
||||
allowedScopes VARCHAR(1024)
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS codes (
|
||||
code BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX codes_client_id(clientId),
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX codes_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
authAt BIGINT DEFAULT 0,
|
||||
offline BOOLEAN DEFAULT FALSE,
|
||||
profileChangedAt BIGINT DEFAULT NULL
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX tokens_client_id(clientId),
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX tokens_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
type VARCHAR(16) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expiresAt TIMESTAMP NOT NULL,
|
||||
profileChangedAt BIGINT DEFAULT NULL,
|
||||
INDEX idx_expiresAt(expiresAt)
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS developers (
|
||||
developerId BINARY(16) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(email)
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clientDevelopers (
|
||||
rowId BINARY(8) PRIMARY KEY,
|
||||
developerId BINARY(16) NOT NULL,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_clientDevelopers_developerId(developerId),
|
||||
INDEX idx_clientDevelopers_clientId(clientId)
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refreshTokens (
|
||||
token BINARY(32) PRIMARY KEY,
|
||||
clientId BINARY(8) NOT NULL,
|
||||
INDEX tokens_client_id(clientId),
|
||||
userId BINARY(16) NOT NULL,
|
||||
INDEX tokens_user_id(userId),
|
||||
email VARCHAR(256) NOT NULL,
|
||||
scope VARCHAR(256) NOT NULL,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
lastUsedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
profileChangedAt BIGINT DEFAULT NULL
|
||||
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
|
||||
|
||||
CREATE TABLE scopes (
|
||||
scope VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
hasScopedKeys BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB;
|
|
@ -0,0 +1,12 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const crypto = require('crypto');
|
||||
const buf = require('buf').hex;
|
||||
|
||||
exports.hash = function hash(value) {
|
||||
var sha = crypto.createHash('sha256');
|
||||
sha.update(buf(value));
|
||||
return sha.digest();
|
||||
};
|
|
@ -0,0 +1,378 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const util = require('util');
|
||||
const hex = require('buf').to.hex;
|
||||
|
||||
const DEFAULTS = {
|
||||
code: 500,
|
||||
error: 'Internal Server Error',
|
||||
errno: 999,
|
||||
info:
|
||||
'https://github.com/mozilla/fxa-oauth-server/blob/master/docs/api.md#errors',
|
||||
message: 'Unspecified error',
|
||||
};
|
||||
|
||||
function merge(target, other) {
|
||||
var keys = Object.keys(other || {});
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
target[keys[i]] = other[keys[i]];
|
||||
}
|
||||
}
|
||||
|
||||
function AppError(options, extra, headers) {
|
||||
this.message = options.message || DEFAULTS.message;
|
||||
this.isBoom = true;
|
||||
if (options.stack) {
|
||||
this.stack = options.stack;
|
||||
} else {
|
||||
Error.captureStackTrace(this, AppError);
|
||||
}
|
||||
this.errno = options.errno || DEFAULTS.errno;
|
||||
this.output = {
|
||||
statusCode: options.code || DEFAULTS.code,
|
||||
payload: {
|
||||
code: options.code || DEFAULTS.code,
|
||||
errno: this.errno,
|
||||
error: options.error || DEFAULTS.error,
|
||||
message: this.message,
|
||||
info: options.info || DEFAULTS.info,
|
||||
},
|
||||
headers: headers || {},
|
||||
};
|
||||
merge(this.output.payload, extra);
|
||||
}
|
||||
util.inherits(AppError, Error);
|
||||
|
||||
AppError.prototype.toString = function() {
|
||||
return 'Error: ' + this.message;
|
||||
};
|
||||
|
||||
AppError.prototype.header = function(name, value) {
|
||||
this.output.headers[name] = value;
|
||||
};
|
||||
|
||||
AppError.isOauthRoute = function isOauthRoute(path) {
|
||||
const routes = require('./routes').routes;
|
||||
return routes.findIndex(r => r.path === path) > -1;
|
||||
};
|
||||
|
||||
AppError.translate = function translate(response) {
|
||||
if (response instanceof AppError) {
|
||||
return response;
|
||||
}
|
||||
|
||||
var error;
|
||||
var payload = response.output.payload;
|
||||
if (payload.validation) {
|
||||
error = AppError.invalidRequestParameter(payload.validation);
|
||||
} else if (payload.statusCode === 415) {
|
||||
error = AppError.invalidContentType();
|
||||
} else {
|
||||
error = new AppError({
|
||||
message: payload.message,
|
||||
code: payload.statusCode,
|
||||
error: payload.error,
|
||||
errno: payload.errno,
|
||||
stack: response.stack,
|
||||
});
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
AppError.prototype.backtrace = function(traced) {
|
||||
this.output.payload.log = traced;
|
||||
};
|
||||
|
||||
AppError.unexpectedError = function unexpectedError() {
|
||||
return new AppError({});
|
||||
};
|
||||
|
||||
AppError.unknownClient = function unknownClient(clientId) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 101,
|
||||
message: 'Unknown client',
|
||||
},
|
||||
{
|
||||
clientId: hex(clientId),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.incorrectSecret = function incorrectSecret(clientId) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 102,
|
||||
message: 'Incorrect secret',
|
||||
},
|
||||
{
|
||||
clientId: hex(clientId),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.incorrectRedirect = function incorrectRedirect(uri) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 103,
|
||||
message: 'Incorrect redirect_uri',
|
||||
},
|
||||
{
|
||||
redirectUri: uri,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.invalidAssertion = function invalidAssertion() {
|
||||
return new AppError({
|
||||
code: 401,
|
||||
error: 'Bad Request',
|
||||
errno: 104,
|
||||
message: 'Invalid assertion',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.unknownCode = function unknownCode(code) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 105,
|
||||
message: 'Unknown code',
|
||||
},
|
||||
{
|
||||
requestCode: code,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.mismatchCode = function mismatchCode(code, clientId) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 106,
|
||||
message: 'Incorrect code',
|
||||
},
|
||||
{
|
||||
requestCode: hex(code),
|
||||
client: hex(clientId),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.expiredCode = function expiredCode(code, expiredAt) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 107,
|
||||
message: 'Expired code',
|
||||
},
|
||||
{
|
||||
requestCode: hex(code),
|
||||
expiredAt: expiredAt,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.invalidToken = function invalidToken() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 108,
|
||||
message: 'Invalid token',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.invalidRequestParameter = function invalidRequestParameter(val) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 109,
|
||||
message: 'Invalid request parameter',
|
||||
},
|
||||
{
|
||||
validation: val,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.invalidResponseType = function invalidResponseType() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 110,
|
||||
message: 'Invalid response_type',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.unauthorized = function unauthorized(reason) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 401,
|
||||
error: 'Unauthorized',
|
||||
errno: 111,
|
||||
message: 'Unauthorized for route',
|
||||
},
|
||||
{
|
||||
detail: reason,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.forbidden = function forbidden() {
|
||||
return new AppError({
|
||||
code: 403,
|
||||
error: 'Forbidden',
|
||||
errno: 112,
|
||||
message: 'Forbidden',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.invalidContentType = function invalidContentType() {
|
||||
return new AppError({
|
||||
code: 415,
|
||||
error: 'Unsupported Media Type',
|
||||
errno: 113,
|
||||
message:
|
||||
'Content-Type must be either application/json or ' +
|
||||
'application/x-www-form-urlencoded',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.invalidScopes = function invalidScopes(scopes) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Invalid scopes',
|
||||
errno: 114,
|
||||
message: 'Requested scopes are not allowed',
|
||||
},
|
||||
{
|
||||
invalidScopes: scopes,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.expiredToken = function expiredToken(expiredAt) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 115,
|
||||
message: 'Expired token',
|
||||
},
|
||||
{
|
||||
expiredAt: expiredAt,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.notPublicClient = function notPublicClient(clientId) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 116,
|
||||
message: 'Not a public client',
|
||||
},
|
||||
{
|
||||
clientId: hex(clientId),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.mismatchCodeChallenge = function mismatchCodeChallenge(pkceHashValue) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 117,
|
||||
message: 'Incorrect code_challenge',
|
||||
},
|
||||
{
|
||||
requestCodeChallenge: pkceHashValue,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.missingPkceParameters = function missingPkceParameters() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'PKCE parameters missing',
|
||||
errno: 118,
|
||||
message: 'Public clients require PKCE OAuth parameters',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.staleAuthAt = function staleAuthAt(authAt) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 401,
|
||||
error: 'Bad Request',
|
||||
errno: 119,
|
||||
message: 'Stale authentication timestamp',
|
||||
},
|
||||
{
|
||||
authAt: authAt,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
AppError.mismatchAcr = function mismatchAcr(foundValue) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 120,
|
||||
message: 'Mismatch acr value',
|
||||
},
|
||||
{ foundValue }
|
||||
);
|
||||
};
|
||||
|
||||
AppError.invalidGrantType = function invalidGrantType() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 121,
|
||||
message: 'Invalid grant_type',
|
||||
});
|
||||
};
|
||||
|
||||
AppError.unknownToken = function unknownToken() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 122,
|
||||
message: 'Unknown token',
|
||||
});
|
||||
};
|
||||
|
||||
// N.B. `errno: 201` is traditionally our generic "service unavailable" error,
|
||||
// so let's reserve it for that purpose here as well.
|
||||
|
||||
AppError.disabledClient = function disabledClient(clientId) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 503,
|
||||
error: 'Client Disabled',
|
||||
errno: 202,
|
||||
message: 'This client has been temporarily disabled',
|
||||
},
|
||||
{ clientId }
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = AppError;
|
|
@ -0,0 +1,258 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const buf = require('buf').hex;
|
||||
const hex = require('buf').to.hex;
|
||||
|
||||
const config = require('../../config');
|
||||
const AppError = require('./error');
|
||||
const db = require('./db');
|
||||
const util = require('./util');
|
||||
const ScopeSet = require('../../../fxa-shared').oauth.scopes;
|
||||
const JWTAccessToken = require('./jwt_access_token');
|
||||
const logger = require('./logging')('grant');
|
||||
const amplitude = require('./metrics/amplitude')(
|
||||
logger,
|
||||
config.getProperties()
|
||||
);
|
||||
const sub = require('./jwt_sub');
|
||||
const subConfig = {
|
||||
subscriptions: {
|
||||
productCapabilities: config.get('subscriptions.productCapabilities'),
|
||||
clientCapabilities: config.get('subscriptions.clientCapabilities'),
|
||||
},
|
||||
};
|
||||
const {
|
||||
determineClientVisibleSubscriptionCapabilities,
|
||||
} = require('../routes/utils/subscriptions');
|
||||
|
||||
const ACR_VALUE_AAL2 = 'AAL2';
|
||||
const ACCESS_TYPE_OFFLINE = 'offline';
|
||||
|
||||
const SCOPE_OPENID = ScopeSet.fromArray(['openid']);
|
||||
const { OAUTH_SCOPE_SESSION_TOKEN } = require('../../lib/constants');
|
||||
|
||||
const ID_TOKEN_EXPIRATION = Math.floor(
|
||||
config.get('oauthServer.openid.ttl') / 1000
|
||||
);
|
||||
|
||||
const jwt = require('./jwt');
|
||||
|
||||
const JWT_ACCESS_TOKENS_ENABLED = config.get(
|
||||
'oauthServer.jwtAccessTokens.enabled'
|
||||
);
|
||||
const JWT_ACCESS_TOKENS_CLIENT_IDS = new Set(
|
||||
config.get('oauthServer.jwtAccessTokens.enabledClientIds')
|
||||
);
|
||||
|
||||
const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([
|
||||
'openid',
|
||||
'profile:uid',
|
||||
'profile:email',
|
||||
'profile:display_name',
|
||||
]);
|
||||
let authdb = {};
|
||||
module.exports.setDB = function(db) {
|
||||
authdb = db;
|
||||
};
|
||||
|
||||
// Given a set of verified user identity claims, can the given client
|
||||
// be granted the specified access to the user's data?
|
||||
//
|
||||
// This is a shared helper function responsible for checking:
|
||||
// * whether the identity claims are sufficient to authorize the requested access
|
||||
// * whether config allows that particular client to request such access at all
|
||||
//
|
||||
// It does *not* perform any user or client authentication, assuming that the
|
||||
// authenticity of the passed-in details has been sufficiently verified by
|
||||
// calling code.
|
||||
module.exports.validateRequestedGrant = async function validateRequestedGrant(
|
||||
verifiedClaims,
|
||||
client,
|
||||
requestedGrant
|
||||
) {
|
||||
requestedGrant.scope = requestedGrant.scope || ScopeSet.fromArray([]);
|
||||
|
||||
// If the grant request is for specific ACR values, do the identity claims support them?
|
||||
if (requestedGrant.acr_values) {
|
||||
const acrTokens = requestedGrant.acr_values.trim().split(/\s+/g);
|
||||
if (
|
||||
acrTokens.includes(ACR_VALUE_AAL2) &&
|
||||
!(verifiedClaims['fxa-aal'] >= 2)
|
||||
) {
|
||||
throw AppError.mismatchAcr(verifiedClaims['fxa-aal']);
|
||||
}
|
||||
}
|
||||
|
||||
// Is an untrusted client requesting scopes that it's not allowed?
|
||||
if (!client.trusted) {
|
||||
const invalidScopes = requestedGrant.scope.difference(
|
||||
UNTRUSTED_CLIENT_ALLOWED_SCOPES
|
||||
);
|
||||
if (!invalidScopes.isEmpty()) {
|
||||
throw AppError.invalidScopes(invalidScopes.getScopeValues());
|
||||
}
|
||||
}
|
||||
|
||||
// For custom scopes (besides the UNTRUSTED_CLIENT_ALLOWED_SCOPES list), is the client allowed to request them?
|
||||
let requiresVerifiedToken = false;
|
||||
const scopeConfig = {};
|
||||
const customScopes = ScopeSet.fromArray([]);
|
||||
for (const scope of requestedGrant.scope.getScopeValues()) {
|
||||
const s = (scopeConfig[scope] = await db.getScope(scope));
|
||||
if (s) {
|
||||
if (s.hasScopedKeys) {
|
||||
// scoped keys require verification, see comment below.
|
||||
requiresVerifiedToken = true;
|
||||
}
|
||||
customScopes.add(scope);
|
||||
}
|
||||
}
|
||||
if (!customScopes.isEmpty()) {
|
||||
const invalidScopes = customScopes.difference(
|
||||
ScopeSet.fromString(client.allowedScopes || '')
|
||||
);
|
||||
if (!invalidScopes.isEmpty()) {
|
||||
throw AppError.invalidScopes(invalidScopes.getScopeValues());
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresVerifiedToken && !verifiedClaims['fxa-tokenVerified']) {
|
||||
// Any request for a key-bearing scope should be using a verified token,
|
||||
// so we can also double-check that here as a defense-in-depth measure.
|
||||
//
|
||||
// Note that this directly reflects the `verified` property of the sessionToken
|
||||
// used to create the assertion, so it can be true for e.g. sessions that were
|
||||
// verified by email before 2FA was enabled on the account. Such sessions must
|
||||
// be able to access sync even after 2FA is enabled, hence checking `verified`
|
||||
// rather than the `aal`-related properties here.
|
||||
throw AppError.invalidAssertion();
|
||||
}
|
||||
|
||||
// If we grow our per-client config, there are more things we could check here:
|
||||
// * Is this client allowed to request ACCESS_TYPE_OFFLINE?
|
||||
// * Is this client allowed to request all the non-key-bearing scopes?
|
||||
// * Do we expect this client to be using OIDC?
|
||||
|
||||
return {
|
||||
clientId: client.id,
|
||||
userId: buf(verifiedClaims.uid),
|
||||
email: verifiedClaims['fxa-verifiedEmail'],
|
||||
scope: requestedGrant.scope,
|
||||
scopeConfig,
|
||||
sessionTokenId: verifiedClaims['fxa-sessionTokenId'],
|
||||
offline: requestedGrant.access_type === ACCESS_TYPE_OFFLINE,
|
||||
authAt: verifiedClaims['fxa-lastAuthAt'],
|
||||
amr: verifiedClaims['fxa-amr'],
|
||||
aal: verifiedClaims['fxa-aal'],
|
||||
profileChangedAt: verifiedClaims['fxa-profileChangedAt'],
|
||||
keysJwe: requestedGrant.keys_jwe,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate tokens that will give the holder all the access in the specified grant.
|
||||
// This always include an access_token, but may also include a refresh_token and/or
|
||||
// id_token if implied by the grant.
|
||||
//
|
||||
// This function does *not* perform any authentication or validation, assuming that
|
||||
// the specified grant has been sufficiently vetted by calling code.
|
||||
module.exports.generateTokens = async function generateTokens(grant) {
|
||||
// We always generate an access_token.
|
||||
const access = await exports.generateAccessToken(grant);
|
||||
|
||||
const result = {
|
||||
access_token: access.jwt_token || access.token.toString('hex'),
|
||||
token_type: access.type,
|
||||
scope: access.scope.toString(),
|
||||
};
|
||||
result.expires_in =
|
||||
grant.ttl || Math.floor((access.expiresAt - Date.now()) / 1000);
|
||||
if (grant.authAt) {
|
||||
result.auth_at = grant.authAt;
|
||||
}
|
||||
if (grant.keysJwe) {
|
||||
result.keys_jwe = grant.keysJwe;
|
||||
}
|
||||
// Maybe also generate a refreshToken?
|
||||
if (grant.offline) {
|
||||
const refresh = await db.generateRefreshToken(grant);
|
||||
result.refresh_token = refresh.token.toString('hex');
|
||||
}
|
||||
// Maybe also generate an idToken?
|
||||
if (grant.scope && grant.scope.contains(SCOPE_OPENID)) {
|
||||
result.id_token = await generateIdToken(grant, result.access_token);
|
||||
}
|
||||
|
||||
if (grant.scope && grant.scope.contains(OAUTH_SCOPE_SESSION_TOKEN)) {
|
||||
result.session_token_id =
|
||||
grant.sessionTokenId && grant.sessionTokenId.toString('hex');
|
||||
}
|
||||
|
||||
amplitude('token.created', {
|
||||
service: hex(grant.clientId),
|
||||
uid: hex(grant.userId),
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
async function generateIdToken(grant, accessToken) {
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
const clientId = hex(grant.clientId);
|
||||
// The IETF spec for `aud` refers to https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
// > REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the
|
||||
// > OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain
|
||||
// > identifiers for other audiences. In the general case, the aud value is an array of
|
||||
// > case sensitive strings. In the common special case when there is one audience, the
|
||||
// > aud value MAY be a single case sensitive string.
|
||||
const audience = grant.resource ? [clientId, grant.resource] : clientId;
|
||||
|
||||
const claims = {
|
||||
sub: await sub(grant.userId, grant.clientId, grant.ppidSeed),
|
||||
aud: audience,
|
||||
//iss set in jwt.sign
|
||||
iat: now,
|
||||
exp: now + ID_TOKEN_EXPIRATION,
|
||||
at_hash: util.generateTokenHash(accessToken),
|
||||
};
|
||||
if (grant.amr) {
|
||||
claims.amr = grant.amr;
|
||||
}
|
||||
if (grant.aal) {
|
||||
claims['fxa-aal'] = grant.aal;
|
||||
claims.acr = 'AAL' + grant.aal;
|
||||
}
|
||||
|
||||
return jwt.sign(claims);
|
||||
}
|
||||
|
||||
exports.generateAccessToken = async function generateAccessToken(grant) {
|
||||
const accessToken = await db.generateAccessToken(grant);
|
||||
if (
|
||||
!JWT_ACCESS_TOKENS_ENABLED ||
|
||||
!JWT_ACCESS_TOKENS_CLIENT_IDS.has(hex(grant.clientId).toLowerCase())
|
||||
) {
|
||||
// return the old style access token if JWT access tokens are
|
||||
// not globally enabled or if not enabled for the given clientId.
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
if (grant.scope.contains('profile:subscriptions')) {
|
||||
const capabilities = await determineClientVisibleSubscriptionCapabilities(
|
||||
subConfig,
|
||||
{},
|
||||
authdb,
|
||||
hex(grant.userId),
|
||||
grant.clientId
|
||||
);
|
||||
// To avoid mutating the input grant, create a
|
||||
// copy and add the new property there.
|
||||
grant = {
|
||||
...grant,
|
||||
'fxa-subscriptions': capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
return JWTAccessToken.create(accessToken, grant);
|
||||
};
|
|
@ -0,0 +1,59 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const jsonwebtoken = require('jsonwebtoken');
|
||||
const P = require('../promise');
|
||||
const { publicPEM, SIGNING_PEM, SIGNING_KID, SIGNING_ALG } = require('./keys');
|
||||
|
||||
const config = require('../../config');
|
||||
const ISSUER = config.get('oauthServer.openid.issuer');
|
||||
|
||||
const jwtverify = P.promisify(jsonwebtoken.verify);
|
||||
|
||||
/**
|
||||
* Sign `claims` using SIGNING_PEM from keys.js, returning a JWT.
|
||||
*
|
||||
* @param {Object} claims
|
||||
* @returns {Promise} resolves with signed JWT when complete
|
||||
*/
|
||||
exports.sign = function sign(claims, options) {
|
||||
return jsonwebtoken.sign(
|
||||
{
|
||||
// force an issuer to be set for direct calls to .sign,
|
||||
// it can be overridden in the passed in claims.
|
||||
iss: ISSUER,
|
||||
...claims,
|
||||
},
|
||||
SIGNING_PEM,
|
||||
{
|
||||
...options,
|
||||
algorithm: SIGNING_ALG,
|
||||
keyid: SIGNING_KID,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
exports.verify = async function verify(jwt, options = {}) {
|
||||
const getKey = (header, callback) => {
|
||||
if (options.typ && header.typ !== options.typ) {
|
||||
return callback(new Error('Invalid typ'));
|
||||
}
|
||||
|
||||
let signingKey;
|
||||
try {
|
||||
signingKey = publicPEM(header.kid);
|
||||
} catch (e) {
|
||||
return callback(new Error('Invalid kid'));
|
||||
}
|
||||
|
||||
callback(null, signingKey);
|
||||
};
|
||||
|
||||
return jwtverify(jwt, getKey, {
|
||||
algorithms: options.algorithms || [SIGNING_ALG],
|
||||
json: true,
|
||||
// use the default issuer unless one is passed in.
|
||||
issuer: options.issuer || ISSUER,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,106 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const AppError = require('./error');
|
||||
const jwt = require('./jwt');
|
||||
const sub = require('./jwt_sub');
|
||||
|
||||
const HEADER_TYP = 'at+JWT';
|
||||
|
||||
/**
|
||||
* Create a JWT access token from `grant`
|
||||
*/
|
||||
exports.create = async function generateJWTAccessToken(accessToken, grant) {
|
||||
const clientId = hex(grant.clientId);
|
||||
// The IETF spec for `aud` refers to https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
// > REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the
|
||||
// > OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain
|
||||
// > identifiers for other audiences. In the general case, the aud value is an array of
|
||||
// > case sensitive strings. In the common special case when there is one audience, the
|
||||
// > aud value MAY be a single case sensitive string.
|
||||
const audience = grant.resource ? [clientId, grant.resource] : clientId;
|
||||
|
||||
// Claims list from:
|
||||
// https://tools.ietf.org/html/draft-bertocci-oauth-access-token-jwt-00#section-2.2
|
||||
const claims = {
|
||||
aud: audience,
|
||||
client_id: clientId,
|
||||
exp: Math.floor(accessToken.expiresAt / 1000),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
// iss is set in jwt.sign
|
||||
jti: hex(accessToken.token),
|
||||
scope: grant.scope.toString(),
|
||||
sub: await sub(grant.userId, grant.clientId, grant.ppidSeed),
|
||||
};
|
||||
|
||||
// Note, a new claim is used rather than scopes because
|
||||
// FxA's scope checking somewhat blindly accepts user input,
|
||||
// meaning a malicious user could reload FxA after editing the URL
|
||||
// to contain subscription name in the scope list and the subscription
|
||||
// would end up in the user's scope list whether they actually
|
||||
// paid for it or not. See https://github.com/mozilla/fxa/issues/2478
|
||||
if (grant['fxa-subscriptions']) {
|
||||
claims['fxa-subscriptions'] = grant['fxa-subscriptions'].join(' ');
|
||||
}
|
||||
|
||||
return {
|
||||
...accessToken,
|
||||
jwt_token: await exports.sign(claims),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign a set of claims to create a JWT access token
|
||||
*
|
||||
* @param {Object} claims
|
||||
* @returns {Promise<JWT>}
|
||||
*/
|
||||
exports.sign = function sign(claims) {
|
||||
return jwt.sign(claims, {
|
||||
header: {
|
||||
typ: HEADER_TYP,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the token ID of the JWT access token.
|
||||
*
|
||||
* @param {String} accessToken
|
||||
* @throws `invalidToken` error if access token is invalid.
|
||||
*/
|
||||
exports.tokenId = async function tokenId(accessToken) {
|
||||
// The access token ID is stored in the jti field of
|
||||
// a JWT access token.
|
||||
const payload = await exports.verify(accessToken);
|
||||
if (!payload.jti) {
|
||||
throw AppError.invalidToken();
|
||||
}
|
||||
return payload.jti;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT access token, return the payload if valid.
|
||||
*
|
||||
* @param {String} accessToken
|
||||
* @throws `invalidToken` error if access token is invalid.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
exports.verify = async function verify(accessToken) {
|
||||
let payload;
|
||||
try {
|
||||
payload = await jwt.verify(accessToken, {
|
||||
typ: HEADER_TYP,
|
||||
});
|
||||
} catch (err) {
|
||||
throw AppError.invalidToken();
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw AppError.invalidToken();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const hkdf = require('../../lib/crypto/hkdf');
|
||||
const config = require('../../config');
|
||||
const validators = require('./validators');
|
||||
|
||||
const PPID_ENABLED = config.get('oauthServer.ppid.enabled');
|
||||
const PPID_CLIENT_IDS = new Set(
|
||||
config.get('oauthServer.ppid.enabledClientIds')
|
||||
);
|
||||
const PPID_ROTATING_CLIENT_IDS = new Set(
|
||||
config.get('oauthServer.ppid.rotatingClientIds')
|
||||
);
|
||||
const PPID_ROTATION_PERIOD_MS = config.get('oauthServer.ppid.rotationPeriodMS');
|
||||
const PPID_SALT = config.get('oauthServer.ppid.salt');
|
||||
const PPID_INFO = 'oidc ppid sub';
|
||||
|
||||
module.exports = async function generateSub(
|
||||
userIdBuf,
|
||||
clientIdBuf,
|
||||
clientSeed = 0
|
||||
) {
|
||||
if (!Buffer.isBuffer(userIdBuf)) {
|
||||
throw new Error('invalid userIdBuf');
|
||||
}
|
||||
if (!Buffer.isBuffer(clientIdBuf)) {
|
||||
throw new Error('invalid clientIdBuf');
|
||||
}
|
||||
if (validators.ppidSeed.validate(clientSeed).error) {
|
||||
throw new Error('invalid ppidSeed');
|
||||
}
|
||||
|
||||
const clientIdHex = hex(clientIdBuf).toLowerCase();
|
||||
const userIdHex = hex(userIdBuf).toLowerCase();
|
||||
|
||||
if (PPID_ENABLED && PPID_CLIENT_IDS.has(clientIdHex)) {
|
||||
// Input values used in the HKDF must not contain a `.` to ensure
|
||||
// collisions are not possible by manipulating the input.
|
||||
// userIdHex is guaranteed to be a hex string and not contain any '.'
|
||||
// clientIdHex is guaranteed to be a hex string and not contain any '.'
|
||||
// clientSeed is a constrained integer between 0 and 1024. See validators.js->ppidSeed
|
||||
let timeBasedContext = 0;
|
||||
if (PPID_ROTATING_CLIENT_IDS.has(clientIdHex)) {
|
||||
timeBasedContext = Math.floor(Date.now() / PPID_ROTATION_PERIOD_MS);
|
||||
}
|
||||
return hex(
|
||||
await hkdf(
|
||||
`${clientIdHex}.${userIdHex}.${clientSeed}.${timeBasedContext}`,
|
||||
PPID_INFO,
|
||||
PPID_SALT,
|
||||
userIdBuf.length
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return userIdHex;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,235 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const assert = require('assert');
|
||||
const config = require('../../config');
|
||||
const { jwk2pem, pem2jwk } = require('pem-jwk');
|
||||
const crypto = require('crypto');
|
||||
const Joi = require('joi');
|
||||
|
||||
const BASE64URL = /^[A-Za-z0-9-_]+$/;
|
||||
|
||||
const PUBLIC_KEY_SCHEMA = (exports.PUBLIC_KEY_SCHEMA = Joi.object({
|
||||
kty: Joi.string()
|
||||
.only('RSA')
|
||||
.required(),
|
||||
kid: Joi.string().required(),
|
||||
n: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
e: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
alg: Joi.string()
|
||||
.only('RS256')
|
||||
.optional(),
|
||||
use: Joi.string()
|
||||
.only('sig')
|
||||
.optional(),
|
||||
'fxa-createdAt': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.optional(),
|
||||
}));
|
||||
|
||||
const PRIVATE_KEY_SCHEMA = (exports.PRIVATE_KEY_SCHEMA = Joi.object({
|
||||
kty: Joi.string()
|
||||
.only('RSA')
|
||||
.required(),
|
||||
kid: Joi.string().required(),
|
||||
n: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
e: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
d: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
alg: Joi.string()
|
||||
.only('RS256')
|
||||
.optional(),
|
||||
use: Joi.string()
|
||||
.only('sig')
|
||||
.optional(),
|
||||
p: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
q: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
dp: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
dq: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
qi: Joi.string()
|
||||
.regex(BASE64URL)
|
||||
.required(),
|
||||
'fxa-createdAt': Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.optional(),
|
||||
}));
|
||||
|
||||
const PRIVATE_JWKS_MAP = new Map();
|
||||
const PUBLIC_JWK_MAP = new Map();
|
||||
const PUBLIC_PEM_MAP = new Map();
|
||||
|
||||
// The active signing key should be a proper RSA private key,
|
||||
// and should always exist (unless we're initializing the keys for the first time).
|
||||
const currentPrivJWK = config.get('oauthServer.openid.key');
|
||||
if (currentPrivJWK) {
|
||||
assert.strictEqual(
|
||||
PRIVATE_KEY_SCHEMA.validate(currentPrivJWK).error,
|
||||
null,
|
||||
'openid.key must be a valid private key'
|
||||
);
|
||||
PRIVATE_JWKS_MAP.set(currentPrivJWK.kid, currentPrivJWK);
|
||||
} else if (!config.get('oauthServer.openid.unsafelyAllowMissingActiveKey')) {
|
||||
assert.fail(
|
||||
'oauthServer.openid.key is missing; bailing out in a cowardly fashion...'
|
||||
);
|
||||
}
|
||||
|
||||
// The pending signing key, if present, should be a proper RSA private key
|
||||
// and must be different from the active key..
|
||||
const newPrivJWK = config.get('oauthServer.openid.newKey');
|
||||
if (newPrivJWK) {
|
||||
assert.strictEqual(
|
||||
PRIVATE_KEY_SCHEMA.validate(newPrivJWK).error,
|
||||
null,
|
||||
'openid.newKey must be a valid private key'
|
||||
);
|
||||
assert.notEqual(
|
||||
currentPrivJWK.kid,
|
||||
newPrivJWK.kid,
|
||||
'openid.key.kid must differ from openid.newKey.id'
|
||||
);
|
||||
PRIVATE_JWKS_MAP.set(newPrivJWK.kid, newPrivJWK);
|
||||
}
|
||||
|
||||
// The retired signing key, if present, should be a proper RSA *public* key.
|
||||
// We will never again sign anything with it, so no need to keep the private component.
|
||||
const oldPubJWK = config.get('oauthServer.openid.oldKey');
|
||||
if (oldPubJWK) {
|
||||
assert.strictEqual(
|
||||
PUBLIC_KEY_SCHEMA.validate(oldPubJWK).error,
|
||||
null,
|
||||
'openid.oldKey must be a valid public key'
|
||||
);
|
||||
assert.notEqual(
|
||||
currentPrivJWK.kid,
|
||||
oldPubJWK.kid,
|
||||
'openid.key.kid must differ from openid.oldKey.id'
|
||||
);
|
||||
PRIVATE_JWKS_MAP.set(oldPubJWK.kid, oldPubJWK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the public portion of a key.
|
||||
*
|
||||
* @param {JWK} The private key.
|
||||
* @returns {JWK} Its corresponding public key.
|
||||
*/
|
||||
exports.extractPublicKey = function extractPublicKey(key) {
|
||||
// Hey, this is important. Listen up.
|
||||
//
|
||||
// This function pulls out only the **PUBLIC** pieces of this key.
|
||||
// For RSA, that's the `e` and `n` values.
|
||||
//
|
||||
// BE CAREFUL IF YOU REFACTOR THIS. Thanks.
|
||||
return {
|
||||
kty: key.kty,
|
||||
alg: key.alg || 'RS256',
|
||||
kid: key.kid,
|
||||
'fxa-createdAt': key['fxa-createdAt'],
|
||||
use: key.use || 'sig',
|
||||
n: key.n,
|
||||
e: key.e,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new, random private key.
|
||||
*
|
||||
* @returns {JWK}
|
||||
*/
|
||||
exports.generatePrivateKey = function generatePrivateKey() {
|
||||
const PEM_ENCODING = {
|
||||
type: 'pkcs1',
|
||||
format: 'pem',
|
||||
};
|
||||
const kp = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 256 * 8,
|
||||
publicKeyEncoding: PEM_ENCODING,
|
||||
privateKeyEncoding: PEM_ENCODING,
|
||||
});
|
||||
// We tag our keys with their creation time, and a unique key id
|
||||
// based on a hash of the public key and the timestamp. The result
|
||||
// comes out like:
|
||||
// {
|
||||
// kid: "20170316-ebe69008"
|
||||
// "fxa-createdAt": 1489716000,
|
||||
// }
|
||||
const now = new Date();
|
||||
const pubKeyFingerprint = crypto
|
||||
.createHash('sha256')
|
||||
.update(kp.publicKey)
|
||||
.digest('hex')
|
||||
.slice(0, 8);
|
||||
const privKey = Object.assign(pem2jwk(kp.privateKey), {
|
||||
kid:
|
||||
now
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replace(/-/g, '') +
|
||||
'-' +
|
||||
pubKeyFingerprint,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
// Timestamp to nearest hour; consumers don't need to know the precise time.
|
||||
'fxa-createdAt': Math.floor(now / 1000 / 3600) * 3600,
|
||||
});
|
||||
return privKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a public PEM by `kid`.
|
||||
*
|
||||
* @param {String} kid of PEM to get
|
||||
* @throws {Error} if no PEM found for `kid`
|
||||
* @returns {JWK}
|
||||
*/
|
||||
exports.publicPEM = function publicPEM(kid) {
|
||||
const pem = PUBLIC_PEM_MAP.get(kid);
|
||||
if (!pem) {
|
||||
throw new Error('PEM not found');
|
||||
}
|
||||
return pem;
|
||||
};
|
||||
|
||||
PRIVATE_JWKS_MAP.forEach((privJWK, kid) => {
|
||||
const publicJWK = exports.extractPublicKey(privJWK);
|
||||
|
||||
PUBLIC_JWK_MAP.set(kid, publicJWK);
|
||||
PUBLIC_PEM_MAP.set(kid, jwk2pem(publicJWK));
|
||||
});
|
||||
|
||||
// An array of raw public keys that can be fetched
|
||||
// by remote services to locally verify.
|
||||
exports.PUBLIC_KEYS = Array.from(PUBLIC_JWK_MAP.values());
|
||||
|
||||
// The PEM to sign with, and related details.
|
||||
// This won't be present if we're unsafely allowing the module to load without
|
||||
// keys property configured.
|
||||
if (currentPrivJWK) {
|
||||
const SIGNING_JWK = currentPrivJWK;
|
||||
const SIGNING_PEM = jwk2pem(SIGNING_JWK);
|
||||
|
||||
exports.SIGNING_PEM = SIGNING_PEM;
|
||||
exports.SIGNING_KID = SIGNING_JWK.kid;
|
||||
exports.SIGNING_ALG = 'RS256';
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
let mozlog = null;
|
||||
|
||||
module.exports = function(nameOrLog) {
|
||||
if (typeof nameOrLog === 'string') {
|
||||
// ignore logger labels for now.
|
||||
// auth and oauth used different logging strategies
|
||||
// eventually these should be consolidated under
|
||||
// lib/log.js
|
||||
if (mozlog) {
|
||||
return mozlog.logger;
|
||||
}
|
||||
// main key_server must set the log
|
||||
if (process.mainModule.filename.includes('key_server')) {
|
||||
throw new Error('uninitialized mozlog');
|
||||
}
|
||||
// probably a test
|
||||
mozlog = {
|
||||
logger: require('mozlog')({ app: 'unknown', level: 'critical' })(
|
||||
nameOrLog
|
||||
),
|
||||
};
|
||||
return mozlog.logger;
|
||||
}
|
||||
mozlog = nameOrLog;
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const logger = require('./')('summary');
|
||||
|
||||
function parsePayload(payload) {
|
||||
var payloadKeys = ['INVALID_PAYLOAD_OBJECT'];
|
||||
try {
|
||||
// given payload object might not be a valid object
|
||||
// See issue #410
|
||||
payloadKeys = Object.keys(payload);
|
||||
} catch (e) {
|
||||
// failed to parse payload keys.
|
||||
}
|
||||
return payloadKeys;
|
||||
}
|
||||
|
||||
module.exports = function summary(request, response) {
|
||||
/*eslint complexity: [2, 11] */
|
||||
if (request.method === 'options') {
|
||||
return;
|
||||
}
|
||||
var payload = request.payload || {};
|
||||
var query = request.query || {};
|
||||
var params = request.params || {};
|
||||
|
||||
var auth = request.auth &&
|
||||
request.auth.credentials && {
|
||||
user: request.auth.credentials.user,
|
||||
scope: request.auth.credentials.scope,
|
||||
};
|
||||
|
||||
var line = {
|
||||
code: response.isBoom ? response.output.statusCode : response.statusCode,
|
||||
errno: response.errno || 0,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
agent: request.headers['user-agent'],
|
||||
t: Date.now() - request.info.received,
|
||||
client_id: payload.client_id || query.client_id || params.client_id,
|
||||
auth: auth,
|
||||
payload: parsePayload(payload),
|
||||
remoteAddressChain: request.app.remoteAddressChain,
|
||||
};
|
||||
|
||||
if (line.code >= 500) {
|
||||
line.stack = response.stack;
|
||||
logger.error('summary', line);
|
||||
} else {
|
||||
logger.info('summary', line);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// This module contains mappings from event names to amplitude event definitions.
|
||||
// A module in fxa-shared is responsible for performing the actual transformations.
|
||||
//
|
||||
// You can see the event taxonomy here:
|
||||
//
|
||||
// https://docs.google.com/spreadsheets/d/1G_8OJGOxeWXdGJ1Ugmykk33Zsl-qAQL05CONSeD4Uz4
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
GROUPS,
|
||||
initialize,
|
||||
} = require('../../../../fxa-shared/metrics/amplitude');
|
||||
const { version: VERSION } = require('../../../package.json');
|
||||
|
||||
const EVENTS = {
|
||||
'token.created': {
|
||||
group: GROUPS.activity,
|
||||
event: 'access_token_created',
|
||||
},
|
||||
'verify.success': {
|
||||
group: GROUPS.activity,
|
||||
event: 'access_token_checked',
|
||||
},
|
||||
};
|
||||
|
||||
const FUZZY_EVENTS = new Map([]);
|
||||
|
||||
function sane(event) {
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
const props = event.event_properties;
|
||||
const excluded =
|
||||
(props.service === 'fennec-stage' &&
|
||||
props.oauth_client_id === '3332a18d142636cb') ||
|
||||
(props.service === 'firefox-desktop' &&
|
||||
props.oauth_client_id === '5882386c6d801776') ||
|
||||
(props.service === 'firefox-ios' &&
|
||||
props.oauth_client_id === '1b1a3e44c54fbb58');
|
||||
return !excluded;
|
||||
}
|
||||
|
||||
module.exports = (log, config) => {
|
||||
if (!log || !config.oauthServer.clientIdToServiceNames) {
|
||||
throw new TypeError('Missing argument');
|
||||
}
|
||||
|
||||
const transformEvent = initialize(
|
||||
config.oauthServer.clientIdToServiceNames,
|
||||
EVENTS,
|
||||
FUZZY_EVENTS
|
||||
);
|
||||
|
||||
return function receiveEvent(event, data) {
|
||||
if (!event || !data) {
|
||||
log.error('amplitude.badArgument', { err: 'Bad argument', event });
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = Object.assign(
|
||||
{},
|
||||
{
|
||||
uid: data.uid,
|
||||
service: data.service,
|
||||
version: VERSION,
|
||||
}
|
||||
);
|
||||
|
||||
const amplitudeEvent = transformEvent(
|
||||
{
|
||||
type: event,
|
||||
time: data.time || Date.now(),
|
||||
},
|
||||
eventData
|
||||
);
|
||||
|
||||
if (sane(amplitudeEvent)) {
|
||||
log.info('amplitudeEvent', amplitudeEvent);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,243 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const Joi = require('joi');
|
||||
const URI = require('urijs');
|
||||
|
||||
const AppError = require('../error');
|
||||
const config = require('../../../config');
|
||||
const db = require('../db');
|
||||
const logger = require('../logging')('routes.authorization');
|
||||
const validators = require('../validators');
|
||||
const { validateRequestedGrant, generateTokens } = require('../grant');
|
||||
const verifyAssertion = require('../assertion');
|
||||
|
||||
const RESPONSE_TYPE_CODE = 'code';
|
||||
const RESPONSE_TYPE_TOKEN = 'token';
|
||||
|
||||
const ACCESS_TYPE_ONLINE = 'online';
|
||||
const ACCESS_TYPE_OFFLINE = 'offline';
|
||||
|
||||
const PKCE_SHA256_CHALLENGE_METHOD = 'S256'; // This server only supports S256 PKCE, no 'plain'
|
||||
const PKCE_CODE_CHALLENGE_LENGTH = 43;
|
||||
|
||||
const MAX_TTL_S = config.get('oauthServer.expiration.accessToken') / 1000;
|
||||
|
||||
const DISABLED_CLIENTS = new Set(config.get('oauthServer.disabledClients'));
|
||||
|
||||
var ALLOWED_SCHEMES = ['https'];
|
||||
|
||||
if (config.get('oauthServer.allowHttpRedirects') === true) {
|
||||
// http scheme used when developing OAuth clients
|
||||
ALLOWED_SCHEMES.push('http');
|
||||
}
|
||||
|
||||
function isLocalHost(url) {
|
||||
var host = new URI(url).hostname();
|
||||
return host === 'localhost' || host === '127.0.0.1';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate: {
|
||||
payload: {
|
||||
client_id: validators.clientId,
|
||||
assertion: validators.assertion.required(),
|
||||
redirect_uri: Joi.string()
|
||||
.max(256)
|
||||
// uri validation ref: https://github.com/hapijs/joi/blob/master/API.md#stringurioptions
|
||||
.uri({
|
||||
scheme: ALLOWED_SCHEMES,
|
||||
}),
|
||||
scope: validators.scope.required(),
|
||||
response_type: Joi.string()
|
||||
.valid(RESPONSE_TYPE_CODE, RESPONSE_TYPE_TOKEN)
|
||||
.default(RESPONSE_TYPE_CODE),
|
||||
state: Joi.string()
|
||||
.max(256)
|
||||
.when('response_type', {
|
||||
is: RESPONSE_TYPE_TOKEN,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.required(),
|
||||
}),
|
||||
ttl: Joi.number()
|
||||
.positive()
|
||||
.max(MAX_TTL_S)
|
||||
.default(MAX_TTL_S)
|
||||
.when('response_type', {
|
||||
is: RESPONSE_TYPE_TOKEN,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
access_type: Joi.string()
|
||||
.valid(ACCESS_TYPE_OFFLINE, ACCESS_TYPE_ONLINE)
|
||||
.default(ACCESS_TYPE_ONLINE)
|
||||
.optional(),
|
||||
code_challenge_method: Joi.string()
|
||||
.valid(PKCE_SHA256_CHALLENGE_METHOD)
|
||||
.when('response_type', {
|
||||
is: RESPONSE_TYPE_CODE,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
})
|
||||
.when('code_challenge', {
|
||||
is: Joi.string().required(),
|
||||
then: Joi.required(),
|
||||
}),
|
||||
code_challenge: Joi.string()
|
||||
.length(PKCE_CODE_CHALLENGE_LENGTH)
|
||||
.when('response_type', {
|
||||
is: RESPONSE_TYPE_CODE,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
keys_jwe: validators.jwe.when('response_type', {
|
||||
is: RESPONSE_TYPE_CODE,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
acr_values: Joi.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.allow(null),
|
||||
|
||||
resource: validators.resourceUrl.when('response_type', {
|
||||
is: RESPONSE_TYPE_TOKEN,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: Joi.object()
|
||||
.keys({
|
||||
redirect: Joi.string(),
|
||||
code: Joi.string(),
|
||||
state: Joi.string(),
|
||||
access_token: validators.accessToken,
|
||||
token_type: Joi.string().valid('bearer'),
|
||||
scope: Joi.string().allow(''),
|
||||
auth_at: Joi.number(),
|
||||
expires_in: Joi.number(),
|
||||
})
|
||||
.with('access_token', ['token_type', 'scope', 'auth_at', 'expires_in'])
|
||||
.with('code', ['state', 'redirect'])
|
||||
.without('code', ['access_token']),
|
||||
},
|
||||
handler: async function authorizationEndpoint(req) {
|
||||
// Refuse to generate new codes or tokens for disabled clients.
|
||||
if (DISABLED_CLIENTS.has(req.payload.client_id)) {
|
||||
throw AppError.disabledClient(req.payload.client_id);
|
||||
}
|
||||
|
||||
const claims = await verifyAssertion(req.payload.assertion);
|
||||
|
||||
const client = await db.getClient(
|
||||
Buffer.from(req.payload.client_id, 'hex')
|
||||
);
|
||||
if (!client) {
|
||||
logger.debug('notFound', { id: req.payload.client_id });
|
||||
throw AppError.unknownClient(req.payload.client_id);
|
||||
}
|
||||
validateClientDetails(client, req.payload);
|
||||
const grant = await validateRequestedGrant(claims, client, req.payload);
|
||||
switch (req.payload.response_type) {
|
||||
case RESPONSE_TYPE_CODE:
|
||||
return await generateAuthorizationCode(client, req.payload, grant);
|
||||
case RESPONSE_TYPE_TOKEN:
|
||||
return await generateImplicitGrant(client, req.payload, grant);
|
||||
default:
|
||||
// Joi validation means this should never happen.
|
||||
logger.critical('joi.response_type', {
|
||||
response_type: req.payload.response_type,
|
||||
});
|
||||
throw AppError.invalidResponseType();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function generateAuthorizationCode(client, payload, grant) {
|
||||
// Clients must use PKCE if and only if they are a pubic client.
|
||||
if (client.publicClient) {
|
||||
if (!payload.code_challenge_method || !payload.code_challenge) {
|
||||
logger.info('client.missingPkceParameters');
|
||||
throw AppError.missingPkceParameters();
|
||||
}
|
||||
} else {
|
||||
if (payload.code_challenge_method || payload.code_challenge) {
|
||||
logger.info('client.notPublicClient');
|
||||
throw AppError.notPublicClient({ id: payload.client_id });
|
||||
}
|
||||
}
|
||||
|
||||
const state = payload.state;
|
||||
|
||||
let code = await db.generateCode(
|
||||
Object.assign(grant, {
|
||||
codeChallengeMethod: payload.code_challenge_method,
|
||||
codeChallenge: payload.code_challenge,
|
||||
sessionTokenId:
|
||||
grant.sessionTokenId && Buffer.from(grant.sessionTokenId, 'hex'),
|
||||
})
|
||||
);
|
||||
code = hex(code);
|
||||
|
||||
const redirect = URI(payload.redirect_uri).addQuery({ code, state });
|
||||
|
||||
return {
|
||||
code,
|
||||
state,
|
||||
redirect: String(redirect),
|
||||
};
|
||||
}
|
||||
|
||||
// N.B. We do not correctly implement the "implicit grant" flow from
|
||||
// RFC6749 which defines `response_type=token`. Instead we have a
|
||||
// privileged set of clients that use `response_type=token` for something
|
||||
// approximating the "resource owner password grant" flow, using an identity
|
||||
// assertion to just directly grant tokens for their own use. Known current
|
||||
// users of this functinality include:
|
||||
//
|
||||
// * Firefox Desktop, for getting "profile"-scoped tokens to access profile data
|
||||
// * Firefox for Android, for getting "profile"-scoped tokens to access profile data
|
||||
// * Firefox for iOS, for getting "profile"-scoped tokens to access profile data
|
||||
//
|
||||
// New clients should not do this, and should instead of `grant_type=fxa-credentials`
|
||||
// on the /token endpoint.
|
||||
//
|
||||
// This route is kept for backwards-compatibility only.
|
||||
async function generateImplicitGrant(client, payload, grant) {
|
||||
if (!client.canGrant) {
|
||||
logger.warn('grantType.notAllowed', {
|
||||
id: hex(client.id),
|
||||
grant_type: 'fxa-credentials',
|
||||
});
|
||||
throw AppError.invalidResponseType();
|
||||
}
|
||||
return generateTokens({
|
||||
...grant,
|
||||
resource: payload.resource,
|
||||
ttl: payload.ttl,
|
||||
});
|
||||
}
|
||||
|
||||
function validateClientDetails(client, payload) {
|
||||
// Clients must use a single specific redirect_uri,
|
||||
// but they're allowed to not provide one and have us fill it in automatically.
|
||||
payload.redirect_uri = payload.redirect_uri || client.redirectUri;
|
||||
if (payload.redirect_uri !== client.redirectUri) {
|
||||
logger.debug('redirect.mismatch', {
|
||||
param: payload.redirect_uri,
|
||||
registered: client.redirectUri,
|
||||
});
|
||||
if (
|
||||
config.get('oauthServer.localRedirects') &&
|
||||
isLocalHost(payload.redirect_uri)
|
||||
) {
|
||||
logger.debug('redirect.local', { uri: payload.redirect_uri });
|
||||
} else {
|
||||
throw AppError.incorrectRedirect(payload.redirect_uri);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const validators = require('../../validators');
|
||||
const db = require('../../db');
|
||||
const error = require('../../error');
|
||||
const verifyAssertion = require('../../assertion');
|
||||
|
||||
module.exports = {
|
||||
validate: {
|
||||
payload: {
|
||||
client_id: validators.clientId,
|
||||
refresh_token_id: validators.token.optional(),
|
||||
assertion: validators.assertion,
|
||||
},
|
||||
},
|
||||
handler: async function(req) {
|
||||
const claims = await verifyAssertion(req.payload.assertion);
|
||||
if (req.payload.refresh_token_id) {
|
||||
if (
|
||||
!(await db.deleteClientRefreshToken(
|
||||
req.payload.refresh_token_id,
|
||||
req.payload.client_id,
|
||||
claims.uid
|
||||
))
|
||||
) {
|
||||
throw error.unknownToken();
|
||||
}
|
||||
} else {
|
||||
await db.deleteClientAuthorization(req.payload.client_id, claims.uid);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
|
@ -0,0 +1,140 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const Joi = require('joi');
|
||||
|
||||
const db = require('../../db');
|
||||
const validators = require('../../validators');
|
||||
const verifyAssertion = require('../../assertion');
|
||||
const ScopeSet = require('../../../../../fxa-shared').oauth.scopes;
|
||||
|
||||
// Helper function to render each returned record in the expected form.
|
||||
function serialize(clientIdHex, token, acceptLanguage) {
|
||||
const createdTime = token.createdAt.getTime();
|
||||
const lastAccessTime = token.lastUsedAt.getTime();
|
||||
return {
|
||||
client_id: clientIdHex,
|
||||
refresh_token_id: token.refreshTokenId
|
||||
? hex(token.refreshTokenId)
|
||||
: undefined,
|
||||
client_name: token.clientName,
|
||||
created_time: createdTime,
|
||||
last_access_time: lastAccessTime,
|
||||
// Sort the scopes alphabetically, for consistent output.
|
||||
scope: token.scope.getScopeValues().sort(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate: {
|
||||
payload: {
|
||||
assertion: validators.assertion.required(),
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: Joi.array().items(
|
||||
Joi.object({
|
||||
client_id: validators.clientId,
|
||||
refresh_token_id: validators.token.optional(),
|
||||
client_name: Joi.string().required(),
|
||||
created_time: Joi.number()
|
||||
.min(0)
|
||||
.required(),
|
||||
last_access_time: Joi.number()
|
||||
.min(0)
|
||||
.required()
|
||||
.allow(null),
|
||||
scope: Joi.array()
|
||||
.items(Joi.string())
|
||||
.required(),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async function(req) {
|
||||
const claims = await verifyAssertion(req.payload.assertion);
|
||||
const authorizedClients = [];
|
||||
|
||||
// First, enumerate all the refresh tokens.
|
||||
// Each of these is a separate instance of an authorized client
|
||||
// and should be displayed to the user as such. Nice and simple!
|
||||
const seenClientIds = new Set();
|
||||
for (const token of await db.getRefreshTokensByUid(claims.uid)) {
|
||||
const clientId = hex(token.clientId);
|
||||
authorizedClients.push(
|
||||
serialize(clientId, token, req.headers['accept-language'])
|
||||
);
|
||||
seenClientIds.add(clientId);
|
||||
}
|
||||
|
||||
// Next, enumerate all the access tokens. In the interests of giving the user a
|
||||
// complete-yet-comprehensible list of all the things attached to their account,
|
||||
// we want to:
|
||||
//
|
||||
// 1. Show a single unified record for any client that is not using refresh tokens.
|
||||
// 2. Avoid showing access tokens for `canGrant` clients; such clients will always
|
||||
// hold some other sort of token, and we don't want them to appear in the list twice.
|
||||
const accessTokenRecordsByClientId = new Map();
|
||||
for (const token of await db.getAccessTokensByUid(claims.uid)) {
|
||||
const clientId = hex(token.clientId);
|
||||
if (!seenClientIds.has(clientId) && !token.clientCanGrant) {
|
||||
let record = accessTokenRecordsByClientId.get(clientId);
|
||||
if (typeof record === 'undefined') {
|
||||
record = {
|
||||
clientId,
|
||||
clientName: token.clientName,
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: token.createdAt,
|
||||
scope: ScopeSet.fromArray([]),
|
||||
};
|
||||
accessTokenRecordsByClientId.set(clientId, record);
|
||||
}
|
||||
// Merge details of all access tokens into a single record.
|
||||
record.scope.add(token.scope);
|
||||
if (token.createdAt < record.createdAt) {
|
||||
record.createdAt = token.createdAt;
|
||||
}
|
||||
if (record.lastUsedAt < token.createdAt) {
|
||||
record.lastUsedAt = token.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [clientId, record] of accessTokenRecordsByClientId.entries()) {
|
||||
authorizedClients.push(
|
||||
serialize(clientId, record, req.headers['accept-language'])
|
||||
);
|
||||
}
|
||||
|
||||
// Sort the final list first by last_access_time, then by client_name, then by created_time.
|
||||
authorizedClients.sort(function(a, b) {
|
||||
if (b.last_access_time > a.last_access_time) {
|
||||
return 1;
|
||||
}
|
||||
if (b.last_access_time < a.last_access_time) {
|
||||
return -1;
|
||||
}
|
||||
if (a.client_name > b.client_name) {
|
||||
return 1;
|
||||
}
|
||||
if (a.client_name < b.client_name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.created_time > b.created_time) {
|
||||
return 1;
|
||||
}
|
||||
if (a.created_time < b.created_time) {
|
||||
return -1;
|
||||
}
|
||||
// To help provide a deterministic result order to simplify testing, also sort of scope values.
|
||||
if (a.scope > b.scope) {
|
||||
return 1;
|
||||
}
|
||||
if (a.scope < b.scope) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return authorizedClients;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const db = require('../../db');
|
||||
const SCOPE_CLIENT_WRITE = require('../../auth_bearer').SCOPE_CLIENT_WRITE;
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: 'authBearer',
|
||||
scope: SCOPE_CLIENT_WRITE.getImplicantValues(),
|
||||
},
|
||||
handler: async function activeServices(req) {
|
||||
var clientId = req.params.client_id;
|
||||
return db
|
||||
.deleteClientAuthorization(clientId, req.auth.credentials.user)
|
||||
.then(function() {
|
||||
return {};
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const config = require('../../../../config');
|
||||
const db = require('../../db');
|
||||
const SCOPE_CLIENT_WRITE = require('../../auth_bearer').SCOPE_CLIENT_WRITE;
|
||||
const localizeTimestamp = require('../../../../../fxa-shared').l10n.localizeTimestamp(
|
||||
{
|
||||
supportedLanguages: config.get('oauthServer.i18n.supportedLanguages'),
|
||||
defaultLanguage: config.get('oauthServer.i18n.defaultLanguage'),
|
||||
}
|
||||
);
|
||||
|
||||
function serialize(client, acceptLanguage) {
|
||||
var lastAccessTime = client.lastAccessTime.getTime();
|
||||
var lastAccessTimeFormatted = localizeTimestamp.format(
|
||||
lastAccessTime,
|
||||
acceptLanguage
|
||||
);
|
||||
|
||||
return {
|
||||
name: client.name,
|
||||
id: hex(client.id),
|
||||
lastAccessTime: lastAccessTime,
|
||||
lastAccessTimeFormatted: lastAccessTimeFormatted,
|
||||
scope: client.scope,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: 'authBearer',
|
||||
scope: SCOPE_CLIENT_WRITE.getImplicantValues(),
|
||||
},
|
||||
handler: async function activeServices(req) {
|
||||
return db
|
||||
.getActiveClientsByUid(req.auth.credentials.user)
|
||||
.then(function(clients) {
|
||||
return clients.map(function(client) {
|
||||
return serialize(client, req.headers['accept-language']);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const auth = require('../../auth_client_management');
|
||||
const db = require('../../db');
|
||||
const validators = require('../../validators');
|
||||
const AppError = require('../../error');
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: auth.AUTH_STRATEGY,
|
||||
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
|
||||
},
|
||||
validate: {
|
||||
params: {
|
||||
client_id: validators.clientId,
|
||||
},
|
||||
},
|
||||
handler: async function clientDeleteEndpoint(req, h) {
|
||||
var email = req.auth.credentials.email;
|
||||
var clientId = req.params.client_id;
|
||||
|
||||
return db.developerOwnsClient(email, clientId).then(
|
||||
function() {
|
||||
return db.removeClient(clientId).then(function() {
|
||||
return h.response({}).code(204);
|
||||
});
|
||||
},
|
||||
function() {
|
||||
throw new AppError.unauthorized('Illegal Developer');
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const hex = require('buf').to.hex;
|
||||
const Joi = require('joi');
|
||||
|
||||
const AppError = require('../../error');
|
||||
const db = require('../../db');
|
||||
const logger = require('../../logging')('routes.client.get');
|
||||
const validators = require('../../validators');
|
||||
|
||||
module.exports = {
|
||||
validate: {
|
||||
params: {
|
||||
client_id: validators.clientId,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
id: validators.clientId,
|
||||
name: Joi.string().required(),
|
||||
trusted: Joi.boolean().required(),
|
||||
image_uri: Joi.any(),
|
||||
redirect_uri: Joi.string()
|
||||
.required()
|
||||
.allow(''),
|
||||
},
|
||||
},
|
||||
handler: async function requestInfoEndpoint(req) {
|
||||
const params = req.params;
|
||||
|
||||
return db
|
||||
.getClient(Buffer.from(params.client_id, 'hex'))
|
||||
.then(function(client) {
|
||||
if (!client) {
|
||||
logger.debug('notFound', { id: params.client_id });
|
||||
throw AppError.unknownClient(params.client_id);
|
||||
} else {
|
||||
return {
|
||||
id: hex(client.id),
|
||||
name: client.name,
|
||||
trusted: client.trusted,
|
||||
image_uri: client.imageUri,
|
||||
redirect_uri: client.redirectUri,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const Joi = require('joi');
|
||||
const hex = require('buf').to.hex;
|
||||
|
||||
const auth = require('../../auth_client_management');
|
||||
const db = require('../../db');
|
||||
const validators = require('../../validators');
|
||||
|
||||
function serialize(client) {
|
||||
return {
|
||||
id: hex(client.id),
|
||||
name: client.name,
|
||||
image_uri: client.imageUri,
|
||||
redirect_uri: client.redirectUri,
|
||||
can_grant: client.canGrant,
|
||||
trusted: client.trusted,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: auth.AUTH_STRATEGY,
|
||||
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
clients: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
id: validators.clientId,
|
||||
name: Joi.string().required(),
|
||||
image_uri: Joi.string().allow(''),
|
||||
redirect_uri: Joi.string()
|
||||
.allow('')
|
||||
.required(),
|
||||
can_grant: Joi.boolean().required(),
|
||||
trusted: Joi.boolean().required(),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
handler: async function listEndpoint(req) {
|
||||
const developerEmail = req.auth.credentials.email;
|
||||
|
||||
return db.getClients(developerEmail).then(function(clients) {
|
||||
return {
|
||||
clients: clients.map(serialize),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const Joi = require('joi');
|
||||
|
||||
const auth = require('../../auth_client_management');
|
||||
const db = require('../../db');
|
||||
const encrypt = require('../../encrypt');
|
||||
const hex = require('buf').to.hex;
|
||||
const unique = require('../../unique');
|
||||
const validators = require('../../validators');
|
||||
const AppError = require('../../error');
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: auth.AUTH_STRATEGY,
|
||||
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
|
||||
},
|
||||
validate: {
|
||||
payload: {
|
||||
name: Joi.string()
|
||||
.max(256)
|
||||
.required(),
|
||||
image_uri: Joi.string()
|
||||
.max(256)
|
||||
.allow(''),
|
||||
redirect_uri: Joi.string()
|
||||
.max(256)
|
||||
.required(),
|
||||
can_grant: Joi.boolean(),
|
||||
trusted: Joi.boolean(),
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
id: validators.clientId,
|
||||
secret: validators.clientSecret,
|
||||
name: Joi.string().required(),
|
||||
image_uri: Joi.string().allow(''),
|
||||
redirect_uri: Joi.string().required(),
|
||||
can_grant: Joi.boolean().required(),
|
||||
trusted: Joi.boolean().required(),
|
||||
},
|
||||
},
|
||||
handler: async function registerEndpoint(req, h) {
|
||||
var payload = req.payload;
|
||||
var secret = unique.secret();
|
||||
var client = {
|
||||
id: unique.id(),
|
||||
hashedSecret: encrypt.hash(secret),
|
||||
name: payload.name,
|
||||
redirectUri: payload.redirect_uri,
|
||||
imageUri: payload.image_uri || '',
|
||||
canGrant: !!payload.can_grant,
|
||||
trusted: !!payload.trusted,
|
||||
};
|
||||
var developerEmail = req.auth.credentials.email;
|
||||
var developerId = null;
|
||||
|
||||
return db
|
||||
.getDeveloper(developerEmail)
|
||||
.then(function(developer) {
|
||||
// must be a developer to register clients
|
||||
if (!developer) {
|
||||
throw AppError.unauthorized('Illegal Developer');
|
||||
}
|
||||
|
||||
developerId = developer.developerId;
|
||||
|
||||
return db.registerClient(client);
|
||||
})
|
||||
.then(function() {
|
||||
return db.registerClientDeveloper(developerId, hex(client.id));
|
||||
})
|
||||
.then(function() {
|
||||
return h
|
||||
.response({
|
||||
id: hex(client.id),
|
||||
secret: hex(secret),
|
||||
name: client.name,
|
||||
redirect_uri: client.redirectUri,
|
||||
image_uri: client.imageUri,
|
||||
can_grant: client.canGrant,
|
||||
trusted: client.trusted,
|
||||
})
|
||||
.code(201);
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const buf = require('buf').hex;
|
||||
const Joi = require('joi');
|
||||
|
||||
const auth = require('../../auth_client_management');
|
||||
const db = require('../../db');
|
||||
const validators = require('../../validators');
|
||||
const AppError = require('../../error');
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: auth.AUTH_STRATEGY,
|
||||
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
|
||||
},
|
||||
validate: {
|
||||
params: {
|
||||
client_id: validators.clientId,
|
||||
},
|
||||
payload: {
|
||||
name: Joi.string().max(256),
|
||||
image_uri: Joi.string().max(256),
|
||||
redirect_uri: Joi.string().max(256),
|
||||
can_grant: Joi.boolean(),
|
||||
},
|
||||
},
|
||||
handler: async function updateClientEndpoint(req, reply) {
|
||||
const clientId = req.params.client_id;
|
||||
const payload = req.payload;
|
||||
const email = req.auth.credentials.email;
|
||||
|
||||
return db.developerOwnsClient(email, clientId).then(
|
||||
function() {
|
||||
return db
|
||||
.updateClient({
|
||||
id: buf(clientId),
|
||||
name: payload.name,
|
||||
redirectUri: payload.redirect_uri,
|
||||
imageUri: payload.image_uri,
|
||||
canGrant: payload.can_grant,
|
||||
})
|
||||
.then(function() {
|
||||
return {};
|
||||
});
|
||||
},
|
||||
function() {
|
||||
throw AppError.unauthorized('Illegal Developer');
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const config = require('../../../config');
|
||||
|
||||
const CONFIG = {
|
||||
browserid: {
|
||||
issuer: config.get('oauthServer.browserid.issuer'),
|
||||
verificationUrl: config.get('oauthServer.browserid.verificationUrl'),
|
||||
},
|
||||
contentUrl: config.get('oauthServer.contentUrl'),
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handler: async function configRoute() {
|
||||
return CONFIG;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const crypto = require('crypto');
|
||||
const Joi = require('joi');
|
||||
const hex = require('buf').to.hex;
|
||||
|
||||
const AppError = require('../error');
|
||||
const db = require('../db');
|
||||
const encrypt = require('../encrypt');
|
||||
const validators = require('../validators');
|
||||
const logger = require('../logging')('routes.destroy');
|
||||
const { getTokenId } = require('../token');
|
||||
const { authenticateClient, clientAuthValidators } = require('../client');
|
||||
|
||||
/*jshint camelcase: false*/
|
||||
|
||||
module.exports = {
|
||||
validate: {
|
||||
headers: clientAuthValidators.headers,
|
||||
payload: Joi.object()
|
||||
.keys({
|
||||
client_id: clientAuthValidators.clientId.optional(),
|
||||
// For historical reasons, we accept and ignore a client_secret if one
|
||||
// is provided without a corresponding client_id.
|
||||
// https://github.com/mozilla/fxa-oauth-server/pull/198
|
||||
client_secret: clientAuthValidators.clientSecret.allow('').optional(),
|
||||
access_token: validators.accessToken,
|
||||
refresh_token: validators.token,
|
||||
refresh_token_id: validators.token,
|
||||
})
|
||||
.rename('token', 'access_token')
|
||||
.xor('access_token', 'refresh_token', 'refresh_token_id'),
|
||||
},
|
||||
handler: async function destroyToken(req) {
|
||||
var token;
|
||||
var getToken;
|
||||
var removeToken;
|
||||
|
||||
// If client credentials were provided, validate them.
|
||||
// For legacy reasons it is possible to call this endpoint without credentials.
|
||||
let client = null;
|
||||
if (req.headers.authorization || req.payload.client_id) {
|
||||
client = await authenticateClient(req.headers, req.payload);
|
||||
}
|
||||
|
||||
if (req.payload.access_token) {
|
||||
getToken = 'getAccessToken';
|
||||
removeToken = 'removeAccessToken';
|
||||
token = await getTokenId(req.payload.access_token);
|
||||
} else {
|
||||
getToken = 'getRefreshToken';
|
||||
removeToken = 'removeRefreshToken';
|
||||
if (req.payload.refresh_token_id) {
|
||||
token = req.payload.refresh_token_id;
|
||||
} else {
|
||||
token = encrypt.hash(req.payload.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
const tokObj = await db[getToken](token);
|
||||
if (!tokObj) {
|
||||
throw AppError.invalidToken();
|
||||
}
|
||||
if (client && !crypto.timingSafeEqual(tokObj.clientId, client.id)) {
|
||||
throw AppError.invalidToken();
|
||||
} else if (!client && req.payload.hasOwnProperty('client_secret')) {
|
||||
// Log a warning if legacy client_secret is provided, so we can
|
||||
// measure whether it's safe to remove this behaviour.
|
||||
logger.warn('destroy.unexpectedClientSecret', {
|
||||
client_id: hex(tokObj.clientId),
|
||||
});
|
||||
}
|
||||
|
||||
await db[removeToken](token);
|
||||
return {};
|
||||
},
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const auth = require('../../auth_client_management');
|
||||
const db = require('../../db');
|
||||
const hex = require('buf').to.hex;
|
||||
|
||||
function developerResponse(developer) {
|
||||
return {
|
||||
developerId: hex(developer.developerId),
|
||||
email: developer.email,
|
||||
createdAt: developer.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
strategy: auth.AUTH_STRATEGY,
|
||||
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues(),
|
||||
},
|
||||
handler: async function activateRegistration(req) {
|
||||
const email = req.auth.credentials.email;
|
||||
|
||||
return db
|
||||
.getDeveloper(email)
|
||||
.then(function(developer) {
|
||||
if (developer) {
|
||||
return developer;
|
||||
} else {
|
||||
return db.activateDeveloper(email);
|
||||
}
|
||||
})
|
||||
.then(developerResponse);
|
||||
},
|
||||
};
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче