Configure node-oidc-provider together with Alexa skills OAuth2 login

227 Views Asked by At

I'm currently using the oidc-provider lib in version 7.14.3, which I recently added to authenticate my users through an Alexa skill, which only supports OAuth2. For this project I can't integrate something like Keycloak and I'm limited to oidc-provider version 7. The Implicit flow works and I get an id_token. However for Alexa I need an access_token retrieved by the Authorization Code flow. I do it with PKCE. When I start this flow, I'll be asked for my credentials and can authorize myself. Then the callback starts, which is working. Now the client wants to get the access token from the /token endpoint and here I get the error 'invalid_request' 'authorization request has expired'. This has to to with that part of the lib. This is my implementation:

AccountService.js:

module.exports = class AccountService {
  ...
  static async findAccount(ctx, email) {
    ...
    return {
      accountId: appUser.id,
      async claims() {
        return {
          sub: appUser.id,
          email: appUser.email,
          active: appUser.active,
          tenantId: appUser.tenantId,
        };
      },
    };
  }

  async authenticateAppUser(email, password) {
    ...
    return appUser.id;
  }
};

boot.js:

const parse = bodyParser.urlencoded({ extended: false });

function setNoCache(req, res, next) {
  res.set('Pragma', 'no-cache');
  res.set('Cache-Control', 'no-cache, no-store');
  next();
}

module.exports = async (app, container) => {
  const oidcService = container.resolve('oidcService');
  const oidcServer = await oidcService.prepareOidcServer();
  ...
  app.get('/auth/oidc/interaction/:uid', setNoCache, async (req, res, next) => oidcService.handleBeforeLoginEntrypoint(req, res, next, oidcServer));
  app.post('/auth/oidc/interaction/:uid/login', setNoCache, parse, async (req, res, next) => oidcService.handleLoginEntrypoint(req, res, next, oidcServer));
  app.post('/auth/oidc/interaction/:uid/confirm', setNoCache, parse, async (req, res, next) => oidcService.handleConfirmGrantEntrypoint(req, res, next, oidcServer));
  app.get('/auth/oidc/interaction/:uid/abort', setNoCache, async (req, res, next) => oidcService.handleLoginCancelEntrypoint(req, res, next, oidcServer));
  app.use('/auth/oidc', oidcServer.callback());
}

OidcService.js:

const { Provider, interactionPolicy: { base } } = require('oidc-provider');

module.exports = class OidcService {
  constructor({
    accountService,
    logger,
    MysqlAdapter,
    OidcClient,
    OidcJwk,
  }) {
    this.accountService = accountService;
    this.logger = logger;
    this.MysqlAdapter = MysqlAdapter;
    this.OidcClient = OidcClient;
    this.OidcJwk = OidcJwk;
  }

  async prepareOidcServer() {
    const clients = {
      [{
        client_id: 'f4b840c6-17f3-4526-a331-a1ca4815289a',
        client_secret: '925c8dc2-20a0-4775-8650-4b18bebd68fa',
        redirect_uris: ['https://oauth.pstmn.io/v1/callback', 'https://layla.amazon.com/api/skill/link/<id>', 'https://pitangui.amazon.com/api/skill/link/<id>', 'https://alexa.amazon.co.jp/api/skill/link/<id>'],
        response_types: ['code'],
        grant_types: ['authorization_code'],
        token_endpoint_auth_method: 'client_secret_post',
      }];
    const jwks = await this.getJwksFromDb();
    const configuration = this.prepareOidcServerConfiguration(clients, jwks);
    const oidcServer = new Provider('https://example.com/auth/oidc/', configuration);
    return this.configureOidcServerErrorHandling(oidcServer);
  }

  prepareOidcServerConfiguration(clients, jwks) {
    return {
      adapter: this.MysqlAdapter,
      claims: {
        openid: ['sub'],
        email: ['email', 'active'],
        tenant: ['tenantId'],
      },
      clients,
      cookies: {
        keys: process.env.AUTH_OIDC_SECURE_KEY.split(','),
      },
      features: {
        devInteractions: { enabled: false },
        deviceFlow: { enabled: true },
        encryption: { enabled: true },
        introspection: { enabled: true },
        jwtResponseModes: { enabled: true },
        jwtUserinfo: { enabled: true },
        pushedAuthorizationRequests: { enabled: true },
        revocation: { enabled: true },
      },
      findAccount: this.accountService.findAccount,
      interactions: {
        policy: base(),
        url(ctx, interaction) {
          return `/auth/oidc/interaction/${interaction.uid}`;
        },
      },
      jwks: {
        keys: jwks,
      },
      proxy: true,
      renderError: this.renderError,
      ttl: {
        AccessToken: 300,
        AuthorizationCode: 1209600,
        ClientCredentials: 300,
        DeviceCode: 1209600,
        IdToken: 1209600,
        RefreshToken: 1209600,
        Session: 1209600,
      },
    };
  }

  async handleBeforeLoginEntrypoint(req, res, next, oidcServer) {
    try {
      const { uid, prompt, params } = await oidcServer.interactionDetails(req, res);
      const oidcClient = await oidcServer.Client.find(params.client_id);

      if (prompt.name === 'login') {
        return this.renderLoginPage(res, oidcClient, uid, prompt, params);
      }

      return this.renderAuthorizeInteraction(res, oidcClient, uid, prompt, params);
    } catch (err) {
      this.logger.err(`An error happened on OIDC entrypoint 'before login'. Error: ${err}`);
      return next(err);
    }
  }

  async handleLoginEntrypoint(req, res, next, oidcServer) {
    try {
      const { uid, prompt, params } = await oidcServer.interactionDetails(req, res);

      if (prompt.name !== 'login') {
        return;
      }

      const oidcClient = await oidcServer.Client.find(params.client_id);
      const accountId = await this.accountService
        .authenticateAppUser(req.body.email, req.body.password);

      if (!accountId) {
        this.renderLoginErrorPage(res, oidcClient, uid, prompt, params, req);
        return;
      }

      const result = {
        login: { accountId },
      };

      await oidcServer.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
    } catch (err) {
      this.logger.err(`An error happened on OIDC entrypoint 'login'. Error: ${err}`);
      next(err);
    }
  }

  async handleConfirmGrantEntrypoint(req, res, next, oidcServer) {
    try {
      const consent = {};
      const {
        grantId,
        prompt: { name, details },
        params,
        session: { accountId },
      } = await oidcServer.interactionDetails(req, res);
      if (name !== 'consent') {
        return;
      }

      const newGrantId = await this.prepareGrant(grantId, oidcServer, accountId, params, details);
      if (!grantId) {
        consent.grantId = newGrantId;
      }

      await oidcServer.interactionFinished(req, res, { consent },
        { mergeWithLastSubmission: true });
    } catch (err) {
      next(err);
    }
  }

  async prepareGrant(grantId, oidcServer, accountId, params, details) {
    let grant;

    if (grantId) {
      // we'll be modifying existing grant in existing session
      grant = await oidcServer.Grant.find(grantId);
    } else {
      // we're establishing a new grant
      grant = new oidcServer.Grant({
        accountId,
        clientId: params.client_id,
      });
    }

    if (details.missingOIDCScope) {
      grant.addOIDCScope(details.missingOIDCScope.join(' '));
      // use grant.rejectOIDCScope to reject a subset or the whole thing
    }
    if (details.missingOIDCClaims) {
      grant.addOIDCClaims(details.missingOIDCClaims);
      // use grant.rejectOIDCClaims to reject a subset or the whole thing
    }
    if (details.missingResourceScopes) {
      // eslint-disable-next-line no-restricted-syntax
      for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
        grant.addResourceScope(indicator, scopes.join(' '));
        // use grant.rejectResourceScope to reject a subset or the whole thing
      }
    }

    return grant.save();
  }

  async handleLoginCancelEntrypoint(req, res, next, oidcServer) {
    try {
      const result = {
        error: 'access_denied',
        error_description: 'End-User aborted interaction',
      };
      await oidcServer.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
    } catch (err) {
      next(err);
    }
  }

  configureOidcServerErrorHandling(oidcServer) {
    oidcServer.on('grant.error', this.handleGlobalOidcErrors);
    oidcServer.on('introspection.error', this.handleGlobalOidcErrors);
    oidcServer.on('revocation.error', this.handleGlobalOidcErrors);
    oidcServer.on('server_error', this.handleGlobalOidcErrors);
    oidcServer.on('access_token.issued', this.handleGlobalOidcErrors);
    return oidcServer;
  }

  // eslint-disable-next-line no-unused-vars
  handleGlobalOidcErrors({ headers: { authorization }, oidc: { body, client } }, err) {
    this.logger.info(`OIDC authentication failed with error message: ${err}`);
  }

  renderLoginPage(res, oidcClient, uid, prompt, params) {
    return res.render('login', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params,
      title: 'Sign-in',
      flash: undefined,
    });
  }

  renderLoginErrorPage(res, oidcClient, uid, prompt, params, req) {
    res.render('login', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params: {
        ...params,
        login_hint: req.body.email,
      },
      title: 'Sign-in',
      flash: 'Invalid email or password.',
    });
  }

  renderAuthorizeInteraction(res, oidcClient, uid, prompt, params) {
    return res.render('interaction', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params,
      title: 'Authorize',
    });
  }

  // eslint-disable-next-line no-unused-vars
  renderError(ctx, out, error) {
    ctx.type = 'html';
    ctx.body = `<!DOCTYPE html>
    <head>
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta charset="utf-8">
      <title>oops! something went wrong</title>
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
      <style>
        @import url(https://fonts.googleapis.com/css?family=Roboto:400,100);h1{font-weight:100;text-align:center;font-size:2.3em}body{font-family:Roboto,sans-serif;margin-top:25px;margin-bottom:25px}.container{padding:0 40px 10px;width:274px;background-color:#F7F7F7;margin:0 auto 10px;border-radius:2px;box-shadow:0 2px 2px rgba(0,0,0,.3);overflow:hidden}pre{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word;margin:0 0 0 1em;text-indent:-1em}
      </style>
    </head>
    <body>
      <div class="container">
        <h1>oops! something went wrong</h1>
        ${Object.entries(out).map(([key, value]) => `<pre><strong>${key}</strong>: ${value}</pre>`).join('')}
      </div>
    </body>
    </html>`;
  }

  async getJwksFromDb() {
    const parsedJwks = [];
    const jwksFromDb = await this.OidcJwk.query().select('key');

    if (!jwksFromDb?.length) {
      return parsedJwks;
    }

    jwksFromDb.forEach((jwk) => {
      parsedJwks.push(this.parseJson(jwk.key));
    });

    return parsedJwks;
  }

  parseJson(value) {
    if (!value) {
      return '';
    }

    try {
      return JSON.parse(value);
    } catch (e) {
      this.logger.error(`Couldn't parse String as JSON with value: ${value}`);
      return '';
    }
  }
};
1

There are 1 best solutions below

0
Amari On

This might not be answering your question however Alexa Account linking doesn't support PKCE. If you are trying to use App-to-App Account Linking (Starting From Your App), you can use authCodeVerifier with Skill Enablement REST API. https://developer.amazon.com/en-US/docs/alexa/smapi/skill-enablement.html#enable-skill-link-request-body-properties